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,21 @@
# == Schema Information
#
# Table name: access_tokens
#
# id :bigint not null, primary key
# owner_type :string
# token :string
# created_at :datetime not null
# updated_at :datetime not null
# owner_id :bigint
#
# Indexes
#
# index_access_tokens_on_owner_type_and_owner_id (owner_type,owner_id)
# index_access_tokens_on_token (token) UNIQUE
#
class AccessToken < ApplicationRecord
has_secure_token :token
belongs_to :owner, polymorphic: true
end

View File

@@ -0,0 +1,224 @@
# == Schema Information
#
# Table name: accounts
#
# id :integer not null, primary key
# auto_resolve_duration :integer
# custom_attributes :jsonb
# domain :string(100)
# feature_flags :bigint default(0), not null
# internal_attributes :jsonb not null
# limits :jsonb
# locale :integer default("en")
# name :string not null
# settings :jsonb
# status :integer default("active")
# support_email :string(100)
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_accounts_on_status (status)
#
class Account < ApplicationRecord
# used for single column multi flags
include FlagShihTzu
include Reportable
include Featurable
include CacheKeys
include CaptainFeaturable
include AccountEmailRateLimitable
SETTINGS_PARAMS_SCHEMA = {
'type': 'object',
'properties':
{
'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 },
'auto_resolve_message': { 'type': %w[string null] },
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
'audio_transcriptions': { 'type': %w[boolean null] },
'auto_resolve_label': { 'type': %w[string null] },
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
'conversation_required_attributes': {
'type': %w[array null],
'items': { 'type': 'string' }
},
'captain_models': {
'type': %w[object null],
'properties': {
'editor': { 'type': %w[string null] },
'assistant': { 'type': %w[string null] },
'copilot': { 'type': %w[string null] },
'label_suggestion': { 'type': %w[string null] },
'audio_transcription': { 'type': %w[string null] },
'help_center_search': { 'type': %w[string null] }
},
'additionalProperties': false
},
'captain_features': {
'type': %w[object null],
'properties': {
'editor': { 'type': %w[boolean null] },
'assistant': { 'type': %w[boolean null] },
'copilot': { 'type': %w[boolean null] },
'label_suggestion': { 'type': %w[boolean null] },
'audio_transcription': { 'type': %w[boolean null] },
'help_center_search': { 'type': %w[boolean null] }
},
'additionalProperties': false
}
},
'required': [],
'additionalProperties': true
}.to_json.freeze
DEFAULT_QUERY_SETTING = {
flag_query_mode: :bit_operator,
check_for_column: false
}.freeze
validates :name, presence: true
validates :domain, length: { maximum: 100 }
validates_with JsonSchemaValidator,
schema: SETTINGS_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.settings }
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :captain_models, :captain_features
store_accessor :settings, :keep_pending_on_bot_failure
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
has_many :agent_bots, dependent: :destroy_async
has_many :api_channels, dependent: :destroy_async, class_name: '::Channel::Api'
has_many :articles, dependent: :destroy_async, class_name: '::Article'
has_many :assignment_policies, dependent: :destroy_async
has_many :automation_rules, dependent: :destroy_async
has_many :macros, dependent: :destroy_async
has_many :campaigns, dependent: :destroy_async
has_many :canned_responses, dependent: :destroy_async
has_many :categories, dependent: :destroy_async, class_name: '::Category'
has_many :contacts, dependent: :destroy_async
has_many :conversations, dependent: :destroy_async
has_many :csat_survey_responses, dependent: :destroy_async
has_many :custom_attribute_definitions, dependent: :destroy_async
has_many :custom_filters, dependent: :destroy_async
has_many :dashboard_apps, dependent: :destroy_async
has_many :data_imports, dependent: :destroy_async
has_many :email_channels, dependent: :destroy_async, class_name: '::Channel::Email'
has_many :facebook_pages, dependent: :destroy_async, class_name: '::Channel::FacebookPage'
has_many :instagram_channels, dependent: :destroy_async, class_name: '::Channel::Instagram'
has_many :tiktok_channels, dependent: :destroy_async, class_name: '::Channel::Tiktok'
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
has_many :inboxes, dependent: :destroy_async
has_many :labels, dependent: :destroy_async
has_many :line_channels, dependent: :destroy_async, class_name: '::Channel::Line'
has_many :mentions, dependent: :destroy_async
has_many :messages, dependent: :destroy_async
has_many :notes, dependent: :destroy_async
has_many :notification_settings, dependent: :destroy_async
has_many :notifications, dependent: :destroy_async
has_many :portals, dependent: :destroy_async, class_name: '::Portal'
has_many :sms_channels, dependent: :destroy_async, class_name: '::Channel::Sms'
has_many :teams, dependent: :destroy_async
has_many :telegram_channels, dependent: :destroy_async, class_name: '::Channel::Telegram'
has_many :twilio_sms, dependent: :destroy_async, class_name: '::Channel::TwilioSms'
has_many :twitter_profiles, dependent: :destroy_async, class_name: '::Channel::TwitterProfile'
has_many :users, through: :account_users
has_many :web_widgets, dependent: :destroy_async, class_name: '::Channel::WebWidget'
has_many :webhooks, dependent: :destroy_async
has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp'
has_many :working_hours, dependent: :destroy_async
has_one_attached :contacts_export
enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true
enum :status, { active: 0, suspended: 1 }
scope :with_auto_resolve, -> { where("(settings ->> 'auto_resolve_after')::int IS NOT NULL") }
before_validation :validate_limit_keys
after_create_commit :notify_creation
after_destroy :remove_account_sequences
def agents
users.where(account_users: { role: :agent })
end
def administrators
users.where(account_users: { role: :administrator })
end
def all_conversation_tags
# returns array of tags
conversation_ids = conversations.pluck(:id)
ActsAsTaggableOn::Tagging.includes(:tag)
.where(context: 'labels',
taggable_type: 'Conversation',
taggable_id: conversation_ids)
.map { |tagging| tagging.tag.name }
end
def webhook_data
{
id: id,
name: name
}
end
def inbound_email_domain
domain.presence || GlobalConfig.get('MAILER_INBOUND_EMAIL_DOMAIN')['MAILER_INBOUND_EMAIL_DOMAIN'] || ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN',
false)
end
def support_email
super.presence || ENV.fetch('MAILER_SENDER_EMAIL') { GlobalConfig.get('MAILER_SUPPORT_EMAIL')['MAILER_SUPPORT_EMAIL'] }
end
def usage_limits
{
agents: ChatwootApp.max_limit.to_i,
inboxes: ChatwootApp.max_limit.to_i
}
end
def locale_english_name
# the locale can also be something like pt_BR, en_US, fr_FR, etc.
# the format is `<locale_code>_<country_code>`
# we need to extract the language code from the locale
account_locale = locale&.split('_')&.first
ISO_639.find(account_locale)&.english_name&.downcase || 'english'
end
private
def notify_creation
Rails.configuration.dispatcher.dispatch(ACCOUNT_CREATED, Time.zone.now, account: self)
end
trigger.after(:insert).for_each(:row) do
"execute format('create sequence IF NOT EXISTS conv_dpid_seq_%s', NEW.id);"
end
trigger.name('camp_dpid_before_insert').after(:insert).for_each(:row) do
"execute format('create sequence IF NOT EXISTS camp_dpid_seq_%s', NEW.id);"
end
def validate_limit_keys
# method overridden in enterprise module
end
def remove_account_sequences
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS camp_dpid_seq_#{id}")
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS conv_dpid_seq_#{id}")
end
end
Account.prepend_mod_with('Account')
Account.prepend_mod_with('Account::PlanUsageAndLimits')
Account.include_mod_with('Concerns::Account')
Account.include_mod_with('Audit::Account')

View File

@@ -0,0 +1,86 @@
# == Schema Information
#
# Table name: account_users
#
# id :bigint not null, primary key
# active_at :datetime
# auto_offline :boolean default(TRUE), not null
# availability :integer default("online"), not null
# role :integer default("agent")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint
# agent_capacity_policy_id :bigint
# custom_role_id :bigint
# inviter_id :bigint
# user_id :bigint
#
# Indexes
#
# index_account_users_on_account_id (account_id)
# index_account_users_on_agent_capacity_policy_id (agent_capacity_policy_id)
# index_account_users_on_custom_role_id (custom_role_id)
# index_account_users_on_user_id (user_id)
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
#
class AccountUser < ApplicationRecord
include AvailabilityStatusable
belongs_to :account
belongs_to :user
belongs_to :inviter, class_name: 'User', optional: true
enum role: { agent: 0, administrator: 1 }
enum availability: { online: 0, offline: 1, busy: 2 }
accepts_nested_attributes_for :account
after_create_commit :notify_creation, :create_notification_setting
after_destroy :notify_deletion, :remove_user_from_account
after_save :update_presence_in_redis, if: :saved_change_to_availability?
validates :user_id, uniqueness: { scope: :account_id }
def create_notification_setting
setting = user.notification_settings.new(account_id: account.id)
setting.selected_email_flags = [:email_conversation_assignment]
setting.selected_push_flags = [:push_conversation_assignment]
setting.save!
end
def remove_user_from_account
::Agents::DestroyJob.perform_later(account, user)
end
def permissions
administrator? ? ['administrator'] : ['agent']
end
def push_event_data
{
id: id,
availability: availability,
role: role,
user_id: user_id
}
end
private
def notify_creation
Rails.configuration.dispatcher.dispatch(AGENT_ADDED, Time.zone.now, account: account)
end
def notify_deletion
Rails.configuration.dispatcher.dispatch(AGENT_REMOVED, Time.zone.now, account: account)
end
def update_presence_in_redis
OnlineStatusTracker.set_status(account.id, user.id, availability)
end
end
AccountUser.prepend_mod_with('AccountUser')
AccountUser.include_mod_with('Audit::AccountUser')
AccountUser.include_mod_with('Concerns::AccountUser')

View File

@@ -0,0 +1,65 @@
# == Schema Information
#
# Table name: agent_bots
#
# id :bigint not null, primary key
# bot_config :jsonb
# bot_type :integer default("webhook")
# description :string
# name :string
# outgoing_url :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint
#
# Indexes
#
# index_agent_bots_on_account_id (account_id)
#
class AgentBot < ApplicationRecord
include AccessTokenable
include Avatarable
scope :accessible_to, lambda { |account|
account_id = account&.id
where(account_id: [nil, account_id])
}
has_many :agent_bot_inboxes, dependent: :destroy_async
has_many :inboxes, through: :agent_bot_inboxes
has_many :messages, as: :sender, dependent: :nullify
has_many :assigned_conversations, class_name: 'Conversation',
foreign_key: :assignee_agent_bot_id,
dependent: :nullify,
inverse_of: :assignee_agent_bot
belongs_to :account, optional: true
enum bot_type: { webhook: 0 }
validates :outgoing_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
def available_name
name
end
def push_event_data(inbox = nil)
{
id: id,
name: name,
avatar_url: avatar_url || inbox&.avatar_url,
type: 'agent_bot'
}
end
def webhook_data
{
id: id,
name: name,
type: 'agent_bot'
}
end
def system_bot?
account.nil?
end
end

View File

@@ -0,0 +1,29 @@
# == Schema Information
#
# Table name: agent_bot_inboxes
#
# id :bigint not null, primary key
# status :integer default("active")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer
# agent_bot_id :integer
# inbox_id :integer
#
class AgentBotInbox < ApplicationRecord
validates :inbox_id, presence: true
validates :agent_bot_id, presence: true
before_validation :ensure_account_id
belongs_to :inbox
belongs_to :agent_bot
belongs_to :account
enum status: { active: 0, inactive: 1 }
private
def ensure_account_id
self.account_id = inbox&.account_id
end
end

View File

@@ -0,0 +1,53 @@
class ApplicationRecord < ActiveRecord::Base
include Events::Types
self.abstract_class = true
before_validation :validates_column_content_length
# the models that exposed in email templates through liquid
def droppables
%w[Account Channel Conversation Inbox User Message]
end
# ModelDrop class should exist in app/drops
def to_drop
return unless droppables.include?(self.class.name)
"#{self.class.name}Drop".constantize.new(self)
end
private
# Generic validation for all columns of type string and text
# Validates the length of the column to prevent DOS via large payloads
# if a custom length validation is already present, skip the validation
def validates_column_content_length
self.class.columns.each do |column|
check_and_validate_content_length(column) if column_of_type_string_or_text?(column)
end
end
def column_of_type_string_or_text?(column)
%i[string text].include?(column.type)
end
def check_and_validate_content_length(column)
length_validator = self.class.validators_on(column.name).find { |v| v.kind == :length }
validate_content_length(column) if length_validator.blank?
end
def validate_content_length(column)
max_length = column.type == :text ? 20_000 : 255
return if self[column.name].nil? || self[column.name].length <= max_length
errors.add(column.name.to_sym, "is too long (maximum is #{max_length} characters)")
end
def normalize_empty_string_to_nil(attrs = [])
attrs.each do |attr|
self[attr] = nil if self[attr].blank?
end
end
end
ApplicationRecord.prepend_mod_with('ApplicationRecord')

View File

@@ -0,0 +1,195 @@
# == Schema Information
#
# Table name: articles
#
# id :bigint not null, primary key
# content :text
# description :text
# locale :string default("en"), not null
# meta :jsonb
# position :integer
# slug :string not null
# status :integer
# title :string
# views :integer
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# associated_article_id :bigint
# author_id :bigint
# category_id :integer
# folder_id :integer
# portal_id :integer not null
#
# Indexes
#
# index_articles_on_account_id (account_id)
# index_articles_on_associated_article_id (associated_article_id)
# index_articles_on_author_id (author_id)
# index_articles_on_portal_id (portal_id)
# index_articles_on_slug (slug) UNIQUE
# index_articles_on_status (status)
# index_articles_on_views (views)
#
class Article < ApplicationRecord
include PgSearch::Model
include LlmFormattable
has_many :associated_articles,
class_name: :Article,
foreign_key: :associated_article_id,
dependent: :nullify,
inverse_of: 'root_article'
belongs_to :root_article,
class_name: :Article,
foreign_key: :associated_article_id,
inverse_of: :associated_articles,
optional: true
belongs_to :account
belongs_to :category, optional: true
belongs_to :portal
belongs_to :author, class_name: 'User', inverse_of: :articles
before_validation :ensure_account_id
before_validation :ensure_article_slug
before_validation :ensure_locale_in_article
validates :account_id, presence: true
validates :author_id, presence: true
validates :title, presence: true
validates :content, presence: true
# ensuring that the position is always set correctly
before_create :add_position_to_article
after_save :category_id_changed_action, if: :saved_change_to_category_id?
enum status: { draft: 0, published: 1, archived: 2 }
scope :search_by_category_slug, ->(category_slug) { where(categories: { slug: category_slug }) if category_slug.present? }
scope :search_by_category_locale, ->(locale) { where(categories: { locale: locale }) if locale.present? }
scope :search_by_locale, ->(locale) { where(locale: locale) if locale.present? }
scope :search_by_author, ->(author_id) { where(author_id: author_id) if author_id.present? }
scope :search_by_status, ->(status) { where(status: status) if status.present? }
scope :order_by_updated_at, -> { reorder(updated_at: :desc) }
scope :order_by_position, -> { reorder(position: :asc) }
scope :order_by_views, -> { reorder(views: :desc) }
# TODO: if text search slows down https://www.postgresql.org/docs/current/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS
# - the A, B and C are for weightage. See: https://github.com/Casecommons/pg_search#weighting
# - the normalization is for ensuring the long articles that mention the search term too many times are not ranked higher.
# it divides rank by log(document_length) to prevent longer articles from ranking higher just due to sizeSee: https://github.com/Casecommons/pg_search#normalization
# - the ranking is to ensure that articles with higher weightage are ranked higher
pg_search_scope(
:text_search,
against: {
title: 'A',
description: 'B',
content: 'C'
},
using: {
tsearch: {
prefix: true,
normalization: 2
}
},
ranked_by: ':tsearch'
)
def self.search(params)
records = left_outer_joins(
:category
).search_by_category_slug(
params[:category_slug]
).search_by_locale(params[:locale]).search_by_author(params[:author_id]).search_by_status(params[:status])
records = records.text_search(params[:query]) if params[:query].present?
records
end
def associate_root_article(associated_article_id)
article = portal.articles.find(associated_article_id) if associated_article_id.present?
return if article.nil?
root_article_id = self.class.find_root_article_id(article)
update(associated_article_id: root_article_id) if root_article_id.present?
end
# Make sure we always associate the parent's associated id to avoid the deeper associations od articles.
def self.find_root_article_id(article)
article.associated_article_id || article.id
end
def draft!
update(status: :draft)
end
def increment_view_count
# rubocop:disable Rails/SkipsModelValidations
update_column(:views, views? ? views + 1 : 1)
# rubocop:enable Rails/SkipsModelValidations
end
def self.update_positions(positions_hash)
positions_hash.each do |article_id, new_position|
# Find the article by its ID and update its position
article = Article.find(article_id)
article.update!(position: new_position)
end
end
private
def category_id_changed_action
# We need to update the position of the article in the new category
return unless persisted?
# this means the article is just created
# and the category_id is newly set
# and the position is already present
return if created_at_before_last_save.nil? && position.present? && category_id_before_last_save.nil?
update_article_position_in_category
end
def ensure_locale_in_article
self.locale = if category.present?
category.locale
else
locale.presence || portal.default_locale
end
end
def add_position_to_article
# on creation if a position is already present, ignore it
return if position.present?
update_article_position_in_category
end
def update_article_position_in_category
max_position = Article.where(category_id: category_id, account_id: account_id).maximum(:position)
new_position = max_position.present? ? max_position + 10 : 10
# update column to avoid validations if the article is already persisted
if persisted?
# rubocop:disable Rails/SkipsModelValidations
update_column(:position, new_position)
# rubocop:enable Rails/SkipsModelValidations
else
self.position = new_position
end
end
def ensure_account_id
self.account_id = portal&.account_id
end
def ensure_article_slug
self.slug ||= "#{Time.now.utc.to_i}-#{title.underscore.parameterize(separator: '-')}" if title.present?
end
end
Article.include_mod_with('Concerns::Article')

View File

@@ -0,0 +1,37 @@
# == Schema Information
#
# Table name: assignment_policies
#
# id :bigint not null, primary key
# assignment_order :integer default("round_robin"), not null
# conversation_priority :integer default("earliest_created"), not null
# description :text
# enabled :boolean default(TRUE), not null
# fair_distribution_limit :integer default(100), not null
# fair_distribution_window :integer default(3600), not null
# name :string(255) not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_assignment_policies_on_account_id (account_id)
# index_assignment_policies_on_account_id_and_name (account_id,name) UNIQUE
# index_assignment_policies_on_enabled (enabled)
#
class AssignmentPolicy < ApplicationRecord
belongs_to :account
has_many :inbox_assignment_policies, dependent: :destroy
has_many :inboxes, through: :inbox_assignment_policies
validates :name, presence: true, uniqueness: { scope: :account_id }
validates :fair_distribution_limit, numericality: { greater_than: 0 }
validates :fair_distribution_window, numericality: { greater_than: 0 }
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
enum assignment_order: { round_robin: 0 } unless ChatwootApp.enterprise?
end
AssignmentPolicy.include_mod_with('Concerns::AssignmentPolicy')

View File

@@ -0,0 +1,188 @@
# == Schema Information
#
# Table name: attachments
#
# id :integer not null, primary key
# coordinates_lat :float default(0.0)
# coordinates_long :float default(0.0)
# extension :string
# external_url :string
# fallback_title :string
# file_type :integer default("image")
# meta :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# message_id :integer not null
#
# Indexes
#
# index_attachments_on_account_id (account_id)
# index_attachments_on_message_id (message_id)
#
class Attachment < ApplicationRecord
include Rails.application.routes.url_helpers
ACCEPTABLE_FILE_TYPES = %w[
text/csv text/plain text/rtf
application/json application/pdf
application/zip application/x-7z-compressed application/vnd.rar application/x-tar
application/msword application/vnd.ms-excel application/vnd.ms-powerpoint application/rtf
application/vnd.oasis.opendocument.text
application/vnd.openxmlformats-officedocument.presentationml.presentation
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
application/vnd.openxmlformats-officedocument.wordprocessingml.document
].freeze
belongs_to :account
belongs_to :message
has_one_attached :file
validate :acceptable_file
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
:contact => 8, :ig_reel => 9, :ig_post => 10, :ig_story => 11, :embed => 12 }
def push_event_data
return unless file_type
base_data.merge(metadata_for_file_type)
end
# NOTE: the URl returned does a 301 redirect to the actual file
def file_url
file.attached? ? url_for(file) : ''
end
# NOTE: for External services use this methods since redirect doesn't work effectively in a lot of cases
def download_url
ActiveStorage::Current.url_options = Rails.application.routes.default_url_options if ActiveStorage::Current.url_options.blank?
file.attached? ? file.blob.url : ''
end
def thumb_url
return '' unless file.attached? && image?
begin
url_for(file.representation(resize_to_fill: [250, nil]))
rescue ActiveStorage::UnrepresentableError => e
Rails.logger.warn "Unrepresentable image attachment: #{id} (#{file.filename}) - #{e.message}"
''
end
end
def with_attached_file?
[:image, :audio, :video, :file].include?(file_type.to_sym)
end
private
def metadata_for_file_type
case file_type.to_sym
when :location
location_metadata
when :fallback
fallback_data
when :contact
contact_metadata
when :audio
audio_metadata
when :embed
embed_data
else
file_metadata
end
end
def embed_data
{
data_url: external_url
}
end
def audio_metadata
audio_file_data = base_data.merge(file_metadata)
audio_file_data.merge(
{
transcribed_text: meta&.[]('transcribed_text') || ''
}
)
end
def file_metadata
metadata = {
extension: extension,
data_url: file_url,
thumb_url: thumb_url,
file_size: file.byte_size,
width: file.metadata[:width],
height: file.metadata[:height]
}
metadata[:data_url] = metadata[:thumb_url] = external_url if message.inbox.instagram? && message.incoming?
metadata
end
def location_metadata
{
coordinates_lat: coordinates_lat,
coordinates_long: coordinates_long,
fallback_title: fallback_title,
data_url: external_url
}
end
def fallback_data
{
fallback_title: fallback_title,
data_url: external_url
}
end
def base_data
{
id: id,
message_id: message_id,
file_type: file_type,
account_id: account_id
}
end
def contact_metadata
{
fallback_title: fallback_title,
meta: meta || {}
}
end
def should_validate_file?
return unless file.attached?
# we are only limiting attachment types in case of website widget
return unless message.inbox.channel_type == 'Channel::WebWidget'
true
end
def acceptable_file
return unless should_validate_file?
validate_file_size(file.byte_size)
validate_file_content_type(file.content_type)
end
def validate_file_content_type(file_content_type)
errors.add(:file, 'type not supported') unless media_file?(file_content_type) || ACCEPTABLE_FILE_TYPES.include?(file_content_type)
end
def validate_file_size(byte_size)
limit_mb = GlobalConfigService.load('MAXIMUM_FILE_UPLOAD_SIZE', 40).to_i
limit_mb = 40 if limit_mb <= 0
errors.add(:file, 'size is too big') if byte_size > limit_mb.megabytes
end
def media_file?(file_content_type)
file_content_type.start_with?('image/', 'video/', 'audio/')
end
end
Attachment.include_mod_with('Concerns::Attachment')

View File

@@ -0,0 +1,109 @@
# == Schema Information
#
# Table name: automation_rules
#
# id :bigint not null, primary key
# actions :jsonb not null
# active :boolean default(TRUE), not null
# conditions :jsonb not null
# description :text
# event_name :string not null
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_automation_rules_on_account_id (account_id)
#
class AutomationRule < ApplicationRecord
include Rails.application.routes.url_helpers
include Reauthorizable
belongs_to :account
has_many_attached :files
validate :json_conditions_format
validate :json_actions_format
validate :query_operator_presence
validate :query_operator_value
validates :account_id, presence: true
after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? }
scope :active, -> { where(active: true) }
def conditions_attributes
%w[content email country_code status message_type browser_language assignee_id team_id referer city company inbox_id
mail_subject phone_number priority conversation_language labels]
end
def actions_attributes
%w[send_message add_label remove_label send_email_to_team assign_team assign_agent send_webhook_event mute_conversation
send_attachment change_status resolve_conversation open_conversation pending_conversation snooze_conversation change_priority
send_email_transcript add_private_note].freeze
end
def file_base_data
files.map do |file|
{
id: file.id,
automation_rule_id: id,
file_type: file.content_type,
account_id: account_id,
file_url: url_for(file),
blob_id: file.blob_id,
filename: file.filename.to_s
}
end
end
private
def json_conditions_format
return if conditions.blank?
attributes = conditions.map { |obj, _| obj['attribute_key'] }
conditions = attributes - conditions_attributes
conditions -= account.custom_attribute_definitions.pluck(:attribute_key)
errors.add(:conditions, "Automation conditions #{conditions.join(',')} not supported.") if conditions.any?
end
def json_actions_format
return if actions.blank?
attributes = actions.map { |obj, _| obj['action_name'] }
actions = attributes - actions_attributes
errors.add(:actions, "Automation actions #{actions.join(',')} not supported.") if actions.any?
end
def query_operator_presence
return if conditions.blank?
operators = conditions.select { |obj, _| obj['query_operator'].nil? }
errors.add(:conditions, 'Automation conditions should have query operator.') if operators.length > 1
end
# This validation ensures logical operators are being used correctly in automation conditions.
# And we don't push any unsanitized query operators to the database.
def query_operator_value
conditions.each do |obj|
validate_single_condition(obj)
end
end
def validate_single_condition(condition)
query_operator = condition['query_operator']
return if query_operator.nil?
return if query_operator.empty?
operator = query_operator.upcase
errors.add(:conditions, 'Query operator must be either "AND" or "OR"') unless %w[AND OR].include?(operator)
end
end
AutomationRule.include_mod_with('Audit::AutomationRule')
AutomationRule.prepend_mod_with('AutomationRule')

View File

@@ -0,0 +1,131 @@
# == Schema Information
#
# Table name: campaigns
#
# id :bigint not null, primary key
# audience :jsonb
# campaign_status :integer default("active"), not null
# campaign_type :integer default("ongoing"), not null
# description :text
# enabled :boolean default(TRUE)
# message :text not null
# scheduled_at :datetime
# template_params :jsonb
# title :string not null
# trigger_only_during_business_hours :boolean default(FALSE)
# trigger_rules :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# display_id :integer not null
# inbox_id :bigint not null
# sender_id :integer
#
# Indexes
#
# index_campaigns_on_account_id (account_id)
# index_campaigns_on_campaign_status (campaign_status)
# index_campaigns_on_campaign_type (campaign_type)
# index_campaigns_on_inbox_id (inbox_id)
# index_campaigns_on_scheduled_at (scheduled_at)
#
class Campaign < ApplicationRecord
include UrlHelper
validates :account_id, presence: true
validates :inbox_id, presence: true
validates :title, presence: true
validates :message, presence: true
validate :validate_campaign_inbox
validate :validate_url
validate :prevent_completed_campaign_from_update, on: :update
validate :sender_must_belong_to_account
validate :inbox_must_belong_to_account
belongs_to :account
belongs_to :inbox
belongs_to :sender, class_name: 'User', optional: true
enum campaign_type: { ongoing: 0, one_off: 1 }
# TODO : enabled attribute is unneccessary . lets move that to the campaign status with additional statuses like draft, disabled etc.
enum campaign_status: { active: 0, completed: 1 }
has_many :conversations, dependent: :nullify, autosave: true
before_validation :ensure_correct_campaign_attributes
after_commit :set_display_id, unless: :display_id?
def trigger!
return unless one_off?
return if completed?
execute_campaign
end
private
def execute_campaign
case inbox.inbox_type
when 'Twilio SMS'
Twilio::OneoffSmsCampaignService.new(campaign: self).perform
when 'Sms'
Sms::OneoffSmsCampaignService.new(campaign: self).perform
when 'Whatsapp'
Whatsapp::OneoffCampaignService.new(campaign: self).perform if account.feature_enabled?(:whatsapp_campaign)
end
end
def set_display_id
reload
end
def validate_campaign_inbox
return unless inbox
errors.add :inbox, 'Unsupported Inbox type' unless ['Website', 'Twilio SMS', 'Sms', 'Whatsapp'].include? inbox.inbox_type
end
# TO-DO we clean up with better validations when campaigns evolve into more inboxes
def ensure_correct_campaign_attributes
return if inbox.blank?
if ['Twilio SMS', 'Sms', 'Whatsapp'].include?(inbox.inbox_type)
self.campaign_type = 'one_off'
self.scheduled_at ||= Time.now.utc
else
self.campaign_type = 'ongoing'
self.scheduled_at = nil
end
end
def validate_url
return unless trigger_rules['url']
use_http_protocol = trigger_rules['url'].starts_with?('http://') || trigger_rules['url'].starts_with?('https://')
errors.add(:url, 'invalid') if inbox.inbox_type == 'Website' && !use_http_protocol
end
def inbox_must_belong_to_account
return unless inbox
return if inbox.account_id == account_id
errors.add(:inbox_id, 'must belong to the same account as the campaign')
end
def sender_must_belong_to_account
return unless sender
return if account.users.exists?(id: sender.id)
errors.add(:sender_id, 'must belong to the same account as the campaign')
end
def prevent_completed_campaign_from_update
errors.add :status, 'The campaign is already completed' if !campaign_status_changed? && completed?
end
# creating db triggers
trigger.before(:insert).for_each(:row) do
"NEW.display_id := nextval('camp_dpid_seq_' || NEW.account_id);"
end
end

View File

@@ -0,0 +1,30 @@
# == Schema Information
#
# Table name: canned_responses
#
# id :integer not null, primary key
# content :text
# short_code :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
class CannedResponse < ApplicationRecord
validates :content, presence: true
validates :short_code, presence: true
validates :account, presence: true
validates :short_code, uniqueness: { scope: :account_id }
belongs_to :account
scope :order_by_search, lambda { |search|
short_code_starts_with = sanitize_sql_array(['WHEN short_code ILIKE ? THEN 1', "#{search}%"])
short_code_like = sanitize_sql_array(['WHEN short_code ILIKE ? THEN 0.5', "%#{search}%"])
content_like = sanitize_sql_array(['WHEN content ILIKE ? THEN 0.2', "%#{search}%"])
order_clause = "CASE #{short_code_starts_with} #{short_code_like} #{content_like} ELSE 0 END"
order(Arel.sql(order_clause) => :desc)
}
end

View File

@@ -0,0 +1,91 @@
# == Schema Information
#
# Table name: categories
#
# id :bigint not null, primary key
# description :text
# icon :string default("")
# locale :string default("en")
# name :string
# position :integer
# slug :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# associated_category_id :bigint
# parent_category_id :bigint
# portal_id :integer not null
#
# Indexes
#
# index_categories_on_associated_category_id (associated_category_id)
# index_categories_on_locale (locale)
# index_categories_on_locale_and_account_id (locale,account_id)
# index_categories_on_parent_category_id (parent_category_id)
# index_categories_on_slug_and_locale_and_portal_id (slug,locale,portal_id) UNIQUE
#
class Category < ApplicationRecord
paginates_per Limits::CATEGORIES_PER_PAGE
belongs_to :account
belongs_to :portal
has_many :folders, dependent: :destroy_async
has_many :articles, dependent: :nullify
has_many :category_related_categories,
class_name: :RelatedCategory,
dependent: :destroy_async
has_many :related_categories,
through: :category_related_categories,
class_name: :Category,
dependent: :nullify
has_many :sub_categories,
class_name: :Category,
foreign_key: :parent_category_id,
dependent: :nullify,
inverse_of: 'parent_category'
has_many :associated_categories,
class_name: :Category,
foreign_key: :associated_category_id,
dependent: :nullify,
inverse_of: 'root_category'
belongs_to :parent_category, class_name: :Category, optional: true
belongs_to :root_category,
class_name: :Category,
foreign_key: :associated_category_id,
inverse_of: :associated_categories,
optional: true
before_validation :ensure_account_id
validates :account_id, presence: true
validates :slug, presence: true
validates :name, presence: true
validate :allowed_locales
validates :locale, uniqueness: { scope: %i[slug portal_id],
message: I18n.t('errors.categories.locale.unique') }
accepts_nested_attributes_for :related_categories
scope :search_by_locale, ->(locale) { where(locale: locale) if locale.present? }
def self.search(params)
search_by_locale(params[:locale]).page(current_page(params)).order(position: :asc)
end
def self.current_page(params)
params[:page] || 1
end
private
def ensure_account_id
self.account_id = portal&.account_id
end
def allowed_locales
return if portal.blank?
allowed_locales = portal.config['allowed_locales']
return true if allowed_locales.include?(locale)
errors.add(:locale, "#{locale} of category is not part of portal's #{allowed_locales}.")
end
end

View File

@@ -0,0 +1,44 @@
# == Schema Information
#
# Table name: channel_api
#
# id :bigint not null, primary key
# additional_attributes :jsonb
# hmac_mandatory :boolean default(FALSE)
# hmac_token :string
# identifier :string
# webhook_url :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_channel_api_on_hmac_token (hmac_token) UNIQUE
# index_channel_api_on_identifier (identifier) UNIQUE
#
class Channel::Api < ApplicationRecord
include Channelable
self.table_name = 'channel_api'
EDITABLE_ATTRS = [:webhook_url, :hmac_mandatory, { additional_attributes: {} }].freeze
has_secure_token :identifier
has_secure_token :hmac_token
validate :ensure_valid_agent_reply_time_window
validates :webhook_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
def name
'API'
end
private
def ensure_valid_agent_reply_time_window
return if additional_attributes['agent_reply_time_window'].blank?
return if additional_attributes['agent_reply_time_window'].to_i.positive?
errors.add(:agent_reply_time_window, 'agent_reply_time_window must be greater than 0')
end
end

View File

@@ -0,0 +1,80 @@
# == Schema Information
#
# Table name: channel_email
#
# id :bigint not null, primary key
# email :string not null
# forward_to_email :string not null
# imap_address :string default("")
# imap_enable_ssl :boolean default(TRUE)
# imap_enabled :boolean default(FALSE)
# imap_login :string default("")
# imap_password :string default("")
# imap_port :integer default(0)
# provider :string
# provider_config :jsonb
# smtp_address :string default("")
# smtp_authentication :string default("login")
# smtp_domain :string default("")
# smtp_enable_ssl_tls :boolean default(FALSE)
# smtp_enable_starttls_auto :boolean default(TRUE)
# smtp_enabled :boolean default(FALSE)
# smtp_login :string default("")
# smtp_openssl_verify_mode :string default("none")
# smtp_password :string default("")
# smtp_port :integer default(0)
# verified_for_sending :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_channel_email_on_email (email) UNIQUE
# index_channel_email_on_forward_to_email (forward_to_email) UNIQUE
#
class Channel::Email < ApplicationRecord
include Channelable
include Reauthorizable
AUTHORIZATION_ERROR_THRESHOLD = 10
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
if Chatwoot.encryption_configured?
encrypts :imap_password
encrypts :smtp_password
end
self.table_name = 'channel_email'
EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl,
:smtp_enabled, :smtp_login, :smtp_password, :smtp_address, :smtp_port, :smtp_domain, :smtp_enable_starttls_auto,
:smtp_enable_ssl_tls, :smtp_openssl_verify_mode, :smtp_authentication, :provider, :verified_for_sending].freeze
validates :email, uniqueness: true
validates :forward_to_email, uniqueness: true
before_validation :ensure_forward_to_email, on: :create
def name
'Email'
end
def microsoft?
provider == 'microsoft'
end
def google?
provider == 'google'
end
def legacy_google?
imap_enabled && imap_address == 'imap.gmail.com'
end
private
def ensure_forward_to_email
self.forward_to_email ||= "#{SecureRandom.hex}@#{account.inbound_email_domain}"
end
end

View File

@@ -0,0 +1,68 @@
# == Schema Information
#
# Table name: channel_facebook_pages
#
# id :integer not null, primary key
# page_access_token :string not null
# user_access_token :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# instagram_id :string
# page_id :string not null
#
# Indexes
#
# index_channel_facebook_pages_on_page_id (page_id)
# index_channel_facebook_pages_on_page_id_and_account_id (page_id,account_id) UNIQUE
#
class Channel::FacebookPage < ApplicationRecord
include Channelable
include Reauthorizable
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
if Chatwoot.encryption_configured?
encrypts :page_access_token
encrypts :user_access_token
end
self.table_name = 'channel_facebook_pages'
validates :page_id, uniqueness: { scope: :account_id }
after_create_commit :subscribe
before_destroy :unsubscribe
def name
'Facebook'
end
def create_contact_inbox(instagram_id, name)
@contact_inbox = ::ContactInboxWithContactBuilder.new({
source_id: instagram_id,
inbox: inbox,
contact_attributes: { name: name }
}).perform
end
def subscribe
# ref https://developers.facebook.com/docs/messenger-platform/reference/webhook-events
Facebook::Messenger::Subscriptions.subscribe(
access_token: page_access_token,
subscribed_fields: %w[
messages message_deliveries message_echoes message_reads standby messaging_handovers
]
)
rescue StandardError => e
Rails.logger.debug { "Rescued: #{e.inspect}" }
true
end
def unsubscribe
Facebook::Messenger::Subscriptions.unsubscribe(access_token: page_access_token)
rescue StandardError => e
Rails.logger.debug { "Rescued: #{e.inspect}" }
true
end
end

View File

@@ -0,0 +1,75 @@
# == Schema Information
#
# Table name: channel_instagram
#
# id :bigint not null, primary key
# access_token :string not null
# expires_at :datetime not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# instagram_id :string not null
#
# Indexes
#
# index_channel_instagram_on_instagram_id (instagram_id) UNIQUE
#
class Channel::Instagram < ApplicationRecord
include Channelable
include Reauthorizable
self.table_name = 'channel_instagram'
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
encrypts :access_token if Chatwoot.encryption_configured?
AUTHORIZATION_ERROR_THRESHOLD = 1
validates :access_token, presence: true
validates :instagram_id, uniqueness: true, presence: true
after_create_commit :subscribe
before_destroy :unsubscribe
def name
'Instagram'
end
def create_contact_inbox(instagram_id, name)
@contact_inbox = ::ContactInboxWithContactBuilder.new({
source_id: instagram_id,
inbox: inbox,
contact_attributes: { name: name }
}).perform
end
def subscribe
# ref https://developers.facebook.com/docs/instagram-platform/webhooks#enable-subscriptions
HTTParty.post(
"https://graph.instagram.com/v22.0/#{instagram_id}/subscribed_apps",
query: {
subscribed_fields: %w[messages message_reactions messaging_seen],
access_token: access_token
}
)
rescue StandardError => e
Rails.logger.debug { "Rescued: #{e.inspect}" }
true
end
def unsubscribe
HTTParty.delete(
"https://graph.instagram.com/v22.0/#{instagram_id}/subscribed_apps",
query: {
access_token: access_token
}
)
true
rescue StandardError => e
Rails.logger.debug { "Rescued: #{e.inspect}" }
true
end
def access_token
Instagram::RefreshOauthTokenService.new(channel: self).access_token
end
end

View File

@@ -0,0 +1,47 @@
# == Schema Information
#
# Table name: channel_line
#
# id :bigint not null, primary key
# line_channel_secret :string not null
# line_channel_token :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# line_channel_id :string not null
#
# Indexes
#
# index_channel_line_on_line_channel_id (line_channel_id) UNIQUE
#
class Channel::Line < ApplicationRecord
include Channelable
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
if Chatwoot.encryption_configured?
encrypts :line_channel_secret
encrypts :line_channel_token
end
self.table_name = 'channel_line'
EDITABLE_ATTRS = [:line_channel_id, :line_channel_secret, :line_channel_token].freeze
validates :line_channel_id, uniqueness: true, presence: true
validates :line_channel_secret, presence: true
validates :line_channel_token, presence: true
def name
'LINE'
end
def client
@client ||= Line::Bot::Client.new do |config|
config.channel_id = line_channel_id
config.channel_secret = line_channel_secret
config.channel_token = line_channel_token
# Skip SSL verification in development to avoid certificate issues
config.http_options = { verify_mode: OpenSSL::SSL::VERIFY_NONE } if Rails.env.development?
end
end
end

View File

@@ -0,0 +1,99 @@
# == Schema Information
#
# Table name: channel_sms
#
# id :bigint not null, primary key
# phone_number :string not null
# provider :string default("default")
# provider_config :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_channel_sms_on_phone_number (phone_number) UNIQUE
#
class Channel::Sms < ApplicationRecord
include Channelable
self.table_name = 'channel_sms'
EDITABLE_ATTRS = [:phone_number, { provider_config: {} }].freeze
validates :phone_number, presence: true, uniqueness: true
# before_save :validate_provider_config
def name
'Sms'
end
# all this should happen in provider service . but hack mode on
def api_base_path
'https://messaging.bandwidth.com/api/v2'
end
def send_message(contact_number, message)
body = message_body(contact_number, message.outgoing_content)
body['media'] = message.attachments.map(&:download_url) if message.attachments.present?
send_to_bandwidth(body, message)
end
def send_text_message(contact_number, message_content)
body = message_body(contact_number, message_content)
send_to_bandwidth(body)
end
private
def message_body(contact_number, message_content)
{
'to' => contact_number,
'from' => phone_number,
'text' => message_content,
'applicationId' => provider_config['application_id']
}
end
def send_to_bandwidth(body, message = nil)
response = HTTParty.post(
"#{api_base_path}/users/#{provider_config['account_id']}/messages",
basic_auth: bandwidth_auth,
headers: { 'Content-Type' => 'application/json' },
body: body.to_json
)
if response.success?
response.parsed_response['id']
else
handle_error(response, message)
nil
end
end
def handle_error(response, message)
Rails.logger.error("[#{account_id}] Error sending SMS: #{response.parsed_response['description']}")
return if message.blank?
# https://dev.bandwidth.com/apis/messaging-apis/messaging/#tag/Messages/operation/createMessage
message.external_error = response.parsed_response['description']
message.status = :failed
message.save!
end
def bandwidth_auth
{ username: provider_config['api_key'], password: provider_config['api_secret'] }
end
# Extract later into provider Service
# let's revisit later
def validate_provider_config
response = HTTParty.post(
"#{api_base_path}/users/#{provider_config['account_id']}/messages",
basic_auth: bandwidth_auth,
headers: { 'Content-Type': 'application/json' }
)
errors.add(:provider_config, 'error setting up') unless response.success?
end
end

View File

@@ -0,0 +1,171 @@
# == Schema Information
#
# Table name: channel_telegram
#
# id :bigint not null, primary key
# bot_name :string
# bot_token :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_channel_telegram_on_bot_token (bot_token) UNIQUE
#
class Channel::Telegram < ApplicationRecord
include Channelable
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
encrypts :bot_token, deterministic: true if Chatwoot.encryption_configured?
self.table_name = 'channel_telegram'
EDITABLE_ATTRS = [:bot_token].freeze
before_validation :ensure_valid_bot_token, on: :create
validates :bot_token, presence: true, uniqueness: true
before_save :setup_telegram_webhook
def name
'Telegram'
end
def telegram_api_url
"https://api.telegram.org/bot#{bot_token}"
end
def send_message_on_telegram(message)
message_id = send_message(message) if message.outgoing_content.present?
message_id = Telegram::SendAttachmentsService.new(message: message).perform if message.attachments.present?
message_id
end
def get_telegram_profile_image(user_id)
# get profile image from telegram
response = HTTParty.get("#{telegram_api_url}/getUserProfilePhotos", query: { user_id: user_id })
return nil unless response.success?
photos = response.parsed_response.dig('result', 'photos')
return if photos.blank?
get_telegram_file_path(photos.first.last['file_id'])
end
def get_telegram_file_path(file_id)
response = HTTParty.get("#{telegram_api_url}/getFile", query: { file_id: file_id })
return nil unless response.success?
"https://api.telegram.org/file/bot#{bot_token}/#{response.parsed_response['result']['file_path']}"
end
def process_error(message, response)
return unless response.parsed_response['ok'] == false
# https://github.com/TelegramBotAPI/errors/tree/master/json
message.external_error = "#{response.parsed_response['error_code']}, #{response.parsed_response['description']}"
message.status = :failed
message.save!
end
def chat_id(message)
message.conversation[:additional_attributes]['chat_id']
end
def business_connection_id(message)
message.conversation[:additional_attributes]['business_connection_id']
end
def reply_to_message_id(message)
message.content_attributes['in_reply_to_external_id']
end
private
def ensure_valid_bot_token
response = HTTParty.get("#{telegram_api_url}/getMe")
unless response.success?
errors.add(:bot_token, 'invalid token')
return
end
self.bot_name = response.parsed_response['result']['username']
end
def setup_telegram_webhook
HTTParty.post("#{telegram_api_url}/deleteWebhook")
response = HTTParty.post("#{telegram_api_url}/setWebhook",
body: {
url: "#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/telegram/#{bot_token}"
})
errors.add(:bot_token, 'error setting up the webook') unless response.success?
end
def send_message(message)
response = message_request(
chat_id(message),
message.outgoing_content,
reply_markup(message),
reply_to_message_id(message),
business_connection_id: business_connection_id(message)
)
process_error(message, response)
response.parsed_response['result']['message_id'] if response.success?
end
def reply_markup(message)
return unless message.content_type == 'input_select'
{
one_time_keyboard: true,
inline_keyboard: message.content_attributes['items'].map do |item|
[{
text: item['title'],
callback_data: item['value']
}]
end
}.to_json
end
def convert_markdown_to_telegram_html(text)
# ref: https://core.telegram.org/bots/api#html-style
# Escape HTML entities first to prevent HTML injection
# This ensures only markdown syntax is converted, not raw HTML
escaped_text = CGI.escapeHTML(text)
# Parse markdown with extensions:
# - strikethrough: support ~~text~~
# - hardbreaks: preserve all newlines as <br>
html = CommonMarker.render_html(escaped_text, [:HARDBREAKS], [:strikethrough]).strip
# Convert paragraph breaks to double newlines to preserve them
# CommonMarker creates <p> tags for paragraph breaks, but Telegram doesn't support <p>
html_with_breaks = html.gsub(%r{</p>\s*<p>}, "\n\n")
# Remove opening and closing <p> tags
html_with_breaks = html_with_breaks.gsub(%r{</?p>}, '')
# Sanitize to only allowed tags
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html_with_breaks, tags: %w[b strong i em u ins s strike del a code pre blockquote],
attributes: %w[href])
# Convert <br /> tags to newlines for Telegram
stripped_html.gsub(%r{<br\s*/?>}, "\n")
end
def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil, business_connection_id: nil)
# text is already converted to HTML by MessageContentPresenter
business_body = {}
business_body[:business_connection_id] = business_connection_id if business_connection_id
HTTParty.post("#{telegram_api_url}/sendMessage",
body: {
chat_id: chat_id,
text: text,
reply_markup: reply_markup,
parse_mode: 'HTML',
reply_to_message_id: reply_to_message_id
}.merge(business_body))
end
end

View File

@@ -0,0 +1,45 @@
# == Schema Information
#
# Table name: channel_tiktok
#
# id :bigint not null, primary key
# access_token :string not null
# expires_at :datetime not null
# refresh_token :string not null
# refresh_token_expires_at :datetime not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# business_id :string not null
#
# Indexes
#
# index_channel_tiktok_on_business_id (business_id) UNIQUE
#
class Channel::Tiktok < ApplicationRecord
include Channelable
include Reauthorizable
self.table_name = 'channel_tiktok'
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
if Chatwoot.encryption_configured?
encrypts :access_token
encrypts :refresh_token
end
AUTHORIZATION_ERROR_THRESHOLD = 1
validates :business_id, uniqueness: true, presence: true
validates :access_token, presence: true
validates :refresh_token, presence: true
validates :expires_at, presence: true
validates :refresh_token_expires_at, presence: true
def name
'Tiktok'
end
def validated_access_token
Tiktok::TokenService.new(channel: self).access_token
end
end

View File

@@ -0,0 +1,78 @@
# == Schema Information
#
# Table name: channel_twilio_sms
#
# id :bigint not null, primary key
# account_sid :string not null
# api_key_sid :string
# auth_token :string not null
# content_templates :jsonb
# content_templates_last_updated :datetime
# medium :integer default("sms")
# messaging_service_sid :string
# phone_number :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_channel_twilio_sms_on_account_sid_and_phone_number (account_sid,phone_number) UNIQUE
# index_channel_twilio_sms_on_messaging_service_sid (messaging_service_sid) UNIQUE
# index_channel_twilio_sms_on_phone_number (phone_number) UNIQUE
#
class Channel::TwilioSms < ApplicationRecord
include Channelable
include Rails.application.routes.url_helpers
self.table_name = 'channel_twilio_sms'
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
encrypts :auth_token if Chatwoot.encryption_configured?
validates :account_sid, presence: true
# The same parameter is used to store api_key_secret if api_key authentication is opted
validates :auth_token, presence: true
EDITABLE_ATTRS = [
:account_sid,
:auth_token
].freeze
# Must have _one_ of messaging_service_sid _or_ phone_number, and messaging_service_sid is preferred
validates :messaging_service_sid, uniqueness: true, presence: true, unless: :phone_number?
validates :phone_number, absence: true, if: :messaging_service_sid?
validates :phone_number, uniqueness: true, allow_nil: true
enum medium: { sms: 0, whatsapp: 1 }
def name
medium == 'sms' ? 'Twilio SMS' : 'Whatsapp'
end
def send_message(to:, body:, media_url: nil)
params = send_message_from.merge(to: to, body: body)
params[:media_url] = media_url if media_url.present?
params[:status_callback] = twilio_delivery_status_index_url
client.messages.create(**params)
end
private
def client
if api_key_sid.present?
Twilio::REST::Client.new(api_key_sid, auth_token, account_sid)
else
Twilio::REST::Client.new(account_sid, auth_token)
end
end
def send_message_from
if messaging_service_sid?
{ messaging_service_sid: messaging_service_sid }
else
{ from: phone_number }
end
end
end

View File

@@ -0,0 +1,68 @@
# == Schema Information
#
# Table name: channel_twitter_profiles
#
# id :bigint not null, primary key
# tweets_enabled :boolean default(TRUE)
# twitter_access_token :string not null
# twitter_access_token_secret :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# profile_id :string not null
#
# Indexes
#
# index_channel_twitter_profiles_on_account_id_and_profile_id (account_id,profile_id) UNIQUE
#
class Channel::TwitterProfile < ApplicationRecord
include Channelable
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
if Chatwoot.encryption_configured?
encrypts :twitter_access_token
encrypts :twitter_access_token_secret
end
self.table_name = 'channel_twitter_profiles'
validates :profile_id, uniqueness: { scope: :account_id }
before_destroy :unsubscribe
EDITABLE_ATTRS = [:tweets_enabled].freeze
def name
'Twitter'
end
def create_contact_inbox(profile_id, name, additional_attributes)
::ContactInboxWithContactBuilder.new({
source_id: profile_id,
inbox: inbox,
contact_attributes: { name: name, additional_attributes: additional_attributes }
}).perform
end
def twitter_client
Twitty::Facade.new do |config|
config.consumer_key = ENV.fetch('TWITTER_CONSUMER_KEY', nil)
config.consumer_secret = ENV.fetch('TWITTER_CONSUMER_SECRET', nil)
config.access_token = twitter_access_token
config.access_token_secret = twitter_access_token_secret
config.base_url = 'https://api.twitter.com'
config.environment = ENV.fetch('TWITTER_ENVIRONMENT', '')
end
end
private
def unsubscribe
### Fix unsubscription with new endpoint
unsubscribe_response = twitter_client.remove_subscription(user_id: profile_id)
Rails.logger.info "TWITTER_UNSUBSCRIBE: #{unsubscribe_response.body}"
rescue StandardError => e
Rails.logger.error e
end
end

View File

@@ -0,0 +1,108 @@
# == Schema Information
#
# Table name: channel_web_widgets
#
# id :integer not null, primary key
# allowed_domains :text default("")
# continuity_via_email :boolean default(TRUE), not null
# feature_flags :integer default(7), not null
# hmac_mandatory :boolean default(FALSE)
# hmac_token :string
# pre_chat_form_enabled :boolean default(FALSE)
# pre_chat_form_options :jsonb
# reply_time :integer default("in_a_few_minutes")
# website_token :string
# website_url :string
# welcome_tagline :string
# welcome_title :string
# widget_color :string default("#1f93ff")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer
#
# Indexes
#
# index_channel_web_widgets_on_hmac_token (hmac_token) UNIQUE
# index_channel_web_widgets_on_website_token (website_token) UNIQUE
#
class Channel::WebWidget < ApplicationRecord
include Channelable
include FlagShihTzu
self.table_name = 'channel_web_widgets'
EDITABLE_ATTRS = [:website_url, :widget_color, :welcome_title, :welcome_tagline, :reply_time, :pre_chat_form_enabled,
:continuity_via_email, :hmac_mandatory, :allowed_domains,
{ pre_chat_form_options: [:pre_chat_message, :require_email,
{ pre_chat_fields:
[:field_type, :label, :placeholder, :name, :enabled, :type, :enabled, :required,
:locale, { values: [] }, :regex_pattern, :regex_cue] }] },
{ selected_feature_flags: [] }].freeze
before_validation :validate_pre_chat_options
validates :website_url, presence: true
validates :widget_color, presence: true
has_many :portals, foreign_key: 'channel_web_widget_id', dependent: :nullify, inverse_of: :channel_web_widget
has_secure_token :website_token
has_secure_token :hmac_token
has_flags 1 => :attachments,
2 => :emoji_picker,
3 => :end_conversation,
4 => :use_inbox_avatar_for_bot,
:column => 'feature_flags',
:check_for_column => false
enum reply_time: { in_a_few_minutes: 0, in_a_few_hours: 1, in_a_day: 2 }
def name
'Website'
end
def web_widget_script
"
<script>
(function(d,t) {
var BASE_URL=\"#{ENV.fetch('FRONTEND_URL', '')}\";
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src=BASE_URL+\"/packs/js/sdk.js\";
g.async = true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
window.chatwootSDK.run({
websiteToken: '#{website_token}',
baseUrl: BASE_URL
})
}
})(document,\"script\");
</script>
"
end
def validate_pre_chat_options
return if pre_chat_form_options.with_indifferent_access['pre_chat_fields'].present?
self.pre_chat_form_options = {
pre_chat_message: 'Share your queries or comments here.',
pre_chat_fields: [
{
'field_type': 'standard', 'label': 'Email Id', 'name': 'emailAddress', 'type': 'email', 'required': true, 'enabled': false
},
{
'field_type': 'standard', 'label': 'Full name', 'name': 'fullName', 'type': 'text', 'required': false, 'enabled': false
},
{
'field_type': 'standard', 'label': 'Phone number', 'name': 'phoneNumber', 'type': 'text', 'required': false, 'enabled': false
}
]
}
end
def create_contact_inbox(additional_attributes = {})
::ContactInboxWithContactBuilder.new({
inbox: inbox,
contact_attributes: { additional_attributes: additional_attributes }
}).perform
end
end

View File

@@ -0,0 +1,96 @@
# == Schema Information
#
# Table name: channel_whatsapp
#
# id :bigint not null, primary key
# message_templates :jsonb
# message_templates_last_updated :datetime
# phone_number :string not null
# provider :string default("default")
# provider_config :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
#
class Channel::Whatsapp < ApplicationRecord
include Channelable
include Reauthorizable
self.table_name = 'channel_whatsapp'
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
# default at the moment is 360dialog lets change later.
PROVIDERS = %w[default whatsapp_cloud].freeze
before_validation :ensure_webhook_verify_token
validates :provider, inclusion: { in: PROVIDERS }
validates :phone_number, presence: true, uniqueness: true
validate :validate_provider_config
after_create :sync_templates
before_destroy :teardown_webhooks
after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks?
def name
'Whatsapp'
end
def provider_service
if provider == 'whatsapp_cloud'
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
else
Whatsapp::Providers::Whatsapp360DialogService.new(whatsapp_channel: self)
end
end
def mark_message_templates_updated
# rubocop:disable Rails/SkipsModelValidations
update_column(:message_templates_last_updated, Time.zone.now)
# rubocop:enable Rails/SkipsModelValidations
end
delegate :send_message, to: :provider_service
delegate :send_template, to: :provider_service
delegate :sync_templates, to: :provider_service
delegate :media_url, to: :provider_service
delegate :api_headers, to: :provider_service
def setup_webhooks
perform_webhook_setup
rescue StandardError => e
Rails.logger.error "[WHATSAPP] Webhook setup failed: #{e.message}"
prompt_reauthorization!
end
private
def ensure_webhook_verify_token
provider_config['webhook_verify_token'] ||= SecureRandom.hex(16) if provider == 'whatsapp_cloud'
end
def validate_provider_config
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
end
def perform_webhook_setup
business_account_id = provider_config['business_account_id']
api_key = provider_config['api_key']
Whatsapp::WebhookSetupService.new(self, business_account_id, api_key).perform
end
def teardown_webhooks
Whatsapp::WebhookTeardownService.new(self).perform
end
def should_auto_setup_webhooks?
# Only auto-setup webhooks for whatsapp_cloud provider with manual setup
# Embedded signup calls setup_webhooks explicitly in EmbeddedSignupService
provider == 'whatsapp_cloud' && provider_config['source'] != 'embedded_signup'
end
end

View File

@@ -0,0 +1,11 @@
module AccessTokenable
extend ActiveSupport::Concern
included do
has_one :access_token, as: :owner, dependent: :destroy_async
after_create :create_access_token
end
def create_access_token
AccessToken.create!(owner: self)
end
end

View File

@@ -0,0 +1,11 @@
module AccountCacheRevalidator
extend ActiveSupport::Concern
included do
after_commit :update_account_cache, on: [:create, :update, :destroy]
end
def update_account_cache
account.update_cache_key(self.class.name.underscore)
end
end

View File

@@ -0,0 +1,53 @@
module AccountEmailRateLimitable
extend ActiveSupport::Concern
OUTBOUND_EMAIL_TTL = 25.hours.to_i
EMAIL_LIMIT_CONFIG_KEY = 'ACCOUNT_EMAILS_LIMIT'.freeze
def email_rate_limit
account_limit || global_limit || default_limit
end
def emails_sent_today
Redis::Alfred.get(email_count_cache_key).to_i
end
def email_transcript_enabled?
true
end
def within_email_rate_limit?
return true if emails_sent_today < email_rate_limit
Rails.logger.warn("Account #{id} reached daily email rate limit of #{email_rate_limit}. Sent: #{emails_sent_today}")
false
end
def increment_email_sent_count
Redis::Alfred.incr(email_count_cache_key).tap do |count|
Redis::Alfred.expire(email_count_cache_key, OUTBOUND_EMAIL_TTL) if count == 1
end
end
private
def email_count_cache_key
@email_count_cache_key ||= format(
Redis::Alfred::ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY,
account_id: id,
date: Time.zone.today.to_s
)
end
def account_limit
self[:limits]&.dig('emails')&.to_i
end
def global_limit
GlobalConfig.get(EMAIL_LIMIT_CONFIG_KEY)[EMAIL_LIMIT_CONFIG_KEY]&.to_i
end
def default_limit
ChatwootApp.max_limit.to_i
end
end

View File

@@ -0,0 +1,130 @@
module ActivityMessageHandler
extend ActiveSupport::Concern
include PriorityActivityMessageHandler
include LabelActivityMessageHandler
include SlaActivityMessageHandler
include TeamActivityMessageHandler
private
def create_activity
user_name = determine_user_name
handle_status_change(user_name)
handle_priority_change(user_name)
handle_label_change(user_name)
handle_sla_policy_change(user_name)
end
def determine_user_name
Current.user&.name
end
def handle_status_change(user_name)
return unless saved_change_to_status?
status_change_activity(user_name)
end
def handle_priority_change(user_name)
return unless saved_change_to_priority?
priority_change_activity(user_name)
end
def handle_label_change(user_name)
return unless saved_change_to_label_list?
create_label_change(activity_message_owner(user_name))
end
def handle_sla_policy_change(user_name)
return unless saved_change_to_sla_policy_id?
sla_change_type = determine_sla_change_type
create_sla_change_activity(sla_change_type, activity_message_owner(user_name))
end
def status_change_activity(user_name)
content = if Current.executed_by.present?
automation_status_change_activity_content
else
user_status_change_activity_content(user_name)
end
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
def auto_resolve_message_key(minutes)
if minutes >= 1440 && (minutes % 1440).zero?
{ key: 'auto_resolved_days', count: minutes / 1440 }
elsif minutes >= 60 && (minutes % 60).zero?
{ key: 'auto_resolved_hours', count: minutes / 60 }
else
{ key: 'auto_resolved_minutes', count: minutes }
end
end
def user_status_change_activity_content(user_name)
if user_name
I18n.t("conversations.activity.status.#{status}", user_name: user_name)
elsif Current.contact.present? && resolved?
I18n.t('conversations.activity.status.contact_resolved', contact_name: Current.contact.name.capitalize)
elsif resolved?
message_data = auto_resolve_message_key(auto_resolve_after || 0)
I18n.t("conversations.activity.status.#{message_data[:key]}", count: message_data[:count])
end
end
def automation_status_change_activity_content
if Current.executed_by.instance_of?(AutomationRule)
I18n.t("conversations.activity.status.#{status}", user_name: I18n.t('automation.system_name'))
elsif Current.executed_by.instance_of?(Contact)
Current.executed_by = nil
I18n.t('conversations.activity.status.system_auto_open')
end
end
def activity_message_params(content)
{ account_id: account_id, inbox_id: inbox_id, message_type: :activity, content: content }
end
def create_muted_message
create_mute_change_activity('muted')
end
def create_unmuted_message
create_mute_change_activity('unmuted')
end
def create_mute_change_activity(change_type)
return unless Current.user
content = I18n.t("conversations.activity.#{change_type}", user_name: Current.user.name)
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
def generate_assignee_change_activity_content(user_name)
params = { assignee_name: assignee&.name || '', user_name: user_name }
key = assignee_id ? 'assigned' : 'removed'
key = 'self_assigned' if self_assign? assignee_id
I18n.t("conversations.activity.assignee.#{key}", **params)
end
def create_assignee_change_activity(user_name)
user_name = activity_message_owner(user_name)
return unless user_name
content = generate_assignee_change_activity_content(user_name)
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
def activity_message_owner(user_name)
user_name = I18n.t('automation.system_name') if !user_name && Current.executed_by.present?
user_name
end
end
ActivityMessageHandler.prepend_mod_with('ActivityMessageHandler')

View File

@@ -0,0 +1,55 @@
module AssignmentHandler
extend ActiveSupport::Concern
include Events::Types
included do
before_save :ensure_assignee_is_from_team
after_commit :notify_assignment_change, :process_assignment_changes
end
private
def ensure_assignee_is_from_team
return unless team_id_changed?
validate_current_assignee_team
self.assignee ||= find_assignee_from_team
end
def validate_current_assignee_team
self.assignee_id = nil if team&.members&.exclude?(assignee)
end
def find_assignee_from_team
return if team&.allow_auto_assign.blank?
team_members_with_capacity = inbox.member_ids_with_assignment_capacity & team.members.ids
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: team_members_with_capacity).find_assignee
end
def notify_assignment_change
{
ASSIGNEE_CHANGED => -> { saved_change_to_assignee_id? },
TEAM_CHANGED => -> { saved_change_to_team_id? }
}.each do |event, condition|
condition.call && dispatcher_dispatch(event, previous_changes)
end
end
def process_assignment_changes
process_assignment_activities
end
def process_assignment_activities
user_name = Current.user.name if Current.user.present?
if saved_change_to_team_id?
create_team_change_activity(user_name)
elsif saved_change_to_assignee_id?
create_assignee_change_activity(user_name)
end
end
def self_assign?(assignee_id)
assignee_id.present? && Current.user&.id == assignee_id
end
end

View File

@@ -0,0 +1,40 @@
module AutoAssignmentHandler
extend ActiveSupport::Concern
include Events::Types
included do
after_save :run_auto_assignment
end
private
def run_auto_assignment
# Round robin kicks in on conversation create & update
# run it only when conversation status changes to open
return unless conversation_status_changed_to_open?
return unless should_run_auto_assignment?
if inbox.auto_assignment_v2_enabled?
# Use new assignment system
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
else
# Use legacy assignment system
# If conversation has a team, only consider team members for assignment
allowed_agent_ids = team_id.present? ? team_member_ids_with_capacity : inbox.member_ids_with_assignment_capacity
AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: allowed_agent_ids).perform
end
end
def team_member_ids_with_capacity
return [] if team.blank? || team.allow_auto_assign.blank?
inbox.member_ids_with_assignment_capacity & team.members.ids
end
def should_run_auto_assignment?
return false unless inbox.enable_auto_assignment?
# run only if assignee is blank or doesn't have access to inbox
assignee.blank? || inbox.members.exclude?(assignee)
end
end

View File

@@ -0,0 +1,30 @@
module AvailabilityStatusable
extend ActiveSupport::Concern
def online_presence?
obj_id = is_a?(Contact) ? id : user_id
::OnlineStatusTracker.get_presence(account_id, self.class.name, obj_id)
end
def availability_status
if is_a? Contact
contact_availability_status
else
user_availability_status
end
end
private
def contact_availability_status
online_presence? ? 'online' : 'offline'
end
def user_availability_status
# we are not considering presence in this case. Just returns the availability
return availability unless auto_offline
# availability as a fallback in case the status is not present in redis
online_presence? ? (::OnlineStatusTracker.get_status(account_id, user_id) || availability) : 'offline'
end
end

View File

@@ -0,0 +1,36 @@
# frozen_string_literal: true
module Avatarable
extend ActiveSupport::Concern
include Rails.application.routes.url_helpers
included do
has_one_attached :avatar
validate :acceptable_avatar, if: -> { avatar.changed? }
after_save :fetch_avatar_from_gravatar
end
def avatar_url
return url_for(avatar.representation(resize_to_fill: [250, nil])) if avatar.attached? && avatar.representable?
''
end
def fetch_avatar_from_gravatar
return unless saved_changes.key?(:email)
return if email.blank?
# Incase avatar_url is supplied, we don't want to fetch avatar from gravatar
# So we will wait for it to be processed
Avatar::AvatarFromGravatarJob.set(wait: 30.seconds).perform_later(self, email)
end
def acceptable_avatar
return unless avatar.attached?
errors.add(:avatar, 'is too big') if avatar.byte_size > 15.megabytes
acceptable_types = ['image/jpeg', 'image/png', 'image/gif'].freeze
errors.add(:avatar, 'filetype not supported') unless acceptable_types.include?(avatar.content_type)
end
end

View File

@@ -0,0 +1,46 @@
module CacheKeys
extend ActiveSupport::Concern
include CacheKeysHelper
include Events::Types
CACHE_KEYS_EXPIRY = 72.hours
included do
class_attribute :cacheable_models
self.cacheable_models = [Label, Inbox, Team]
end
def cache_keys
keys = {}
self.class.cacheable_models.each do |model|
keys[model.name.underscore.to_sym] = fetch_value_for_key(id, model.name.underscore)
end
keys
end
def update_cache_key(key)
update_cache_key_for_account(id, key)
dispatch_cache_update_event
end
def reset_cache_keys
self.class.cacheable_models.each do |model|
update_cache_key_for_account(id, model.name.underscore)
end
dispatch_cache_update_event
end
private
def update_cache_key_for_account(account_id, key)
prefixed_cache_key = get_prefixed_cache_key(account_id, key)
Redis::Alfred.setex(prefixed_cache_key, Time.now.utc.to_i, CACHE_KEYS_EXPIRY)
end
def dispatch_cache_update_event
Rails.configuration.dispatcher.dispatch(ACCOUNT_CACHE_INVALIDATED, Time.zone.now, cache_keys: cache_keys, account: self)
end
end

View File

@@ -0,0 +1,62 @@
# frozen_string_literal: true
module CaptainFeaturable
extend ActiveSupport::Concern
included do
validate :validate_captain_models
# Dynamically define accessor methods for each captain feature
Llm::Models.feature_keys.each do |feature_key|
# Define enabled? methods (e.g., captain_editor_enabled?)
define_method("captain_#{feature_key}_enabled?") do
captain_features_with_defaults[feature_key]
end
# Define model accessor methods (e.g., captain_editor_model)
define_method("captain_#{feature_key}_model") do
captain_models_with_defaults[feature_key]
end
end
end
def captain_preferences
{
models: captain_models_with_defaults,
features: captain_features_with_defaults
}.with_indifferent_access
end
private
def captain_models_with_defaults
stored_models = captain_models || {}
Llm::Models.feature_keys.each_with_object({}) do |feature_key, result|
stored_value = stored_models[feature_key]
result[feature_key] = if stored_value.present? && Llm::Models.valid_model_for?(feature_key, stored_value)
stored_value
else
Llm::Models.default_model_for(feature_key)
end
end
end
def captain_features_with_defaults
stored_features = captain_features || {}
Llm::Models.feature_keys.index_with do |feature_key|
stored_features[feature_key] == true
end
end
def validate_captain_models
return if captain_models.blank?
captain_models.each do |feature_key, model_name|
next if model_name.blank?
next if Llm::Models.valid_model_for?(feature_key, model_name)
allowed_models = Llm::Models.models_for(feature_key)
errors.add(:captain_models, "'#{model_name}' is not a valid model for #{feature_key}. Allowed: #{allowed_models.join(', ')}")
end
end
end

View File

@@ -0,0 +1,13 @@
module Channelable
extend ActiveSupport::Concern
included do
validates :account_id, presence: true
belongs_to :account
has_one :inbox, as: :channel, dependent: :destroy_async, touch: true
after_update :create_audit_log_entry
end
def create_audit_log_entry; end
end
Channelable.prepend_mod_with('Channelable')

View File

@@ -0,0 +1,52 @@
class ContentAttributeValidator < ActiveModel::Validator
ALLOWED_SELECT_ITEM_KEYS = [:title, :value].freeze
ALLOWED_CARD_ITEM_KEYS = [:title, :description, :media_url, :actions].freeze
ALLOWED_CARD_ITEM_ACTION_KEYS = [:text, :type, :payload, :uri].freeze
ALLOWED_FORM_ITEM_KEYS = [:type, :placeholder, :label, :name, :options, :default, :required, :pattern, :title, :pattern_error].freeze
ALLOWED_ARTICLE_KEYS = [:title, :description, :link].freeze
def validate(record)
case record.content_type
when 'input_select'
validate_items!(record)
validate_item_attributes!(record, ALLOWED_SELECT_ITEM_KEYS)
when 'cards'
validate_items!(record)
validate_item_attributes!(record, ALLOWED_CARD_ITEM_KEYS)
validate_item_actions!(record)
when 'form'
validate_items!(record)
validate_item_attributes!(record, ALLOWED_FORM_ITEM_KEYS)
when 'article'
validate_items!(record)
validate_item_attributes!(record, ALLOWED_ARTICLE_KEYS)
end
end
private
def validate_items!(record)
record.errors.add(:content_attributes, 'At least one item is required.') if record.items.blank?
record.errors.add(:content_attributes, 'Items should be a hash.') if record.items.reject { |item| item.is_a?(Hash) }.present?
end
def validate_item_attributes!(record, valid_keys)
item_keys = record.items.collect(&:keys).flatten.filter_map(&:to_sym)
invalid_keys = item_keys - valid_keys
record.errors.add(:content_attributes, "contains invalid keys for items : #{invalid_keys}") if invalid_keys.present?
end
def validate_item_actions!(record)
if record.items.select { |item| item[:actions].blank? }.present?
record.errors.add(:content_attributes, 'contains items missing actions') && return
end
validate_item_action_attributes!(record)
end
def validate_item_action_attributes!(record)
item_action_keys = record.items.collect { |item| item[:actions].collect(&:keys) }
invalid_keys = item_action_keys.flatten.compact.map(&:to_sym) - ALLOWED_CARD_ITEM_ACTION_KEYS
record.errors.add(:content_attributes, "contains invalid keys for actions: #{invalid_keys}") if invalid_keys.present?
end
end

View File

@@ -0,0 +1,22 @@
module ConversationMuteHelpers
extend ActiveSupport::Concern
def mute!
return unless contact
resolved!
contact.update(blocked: true)
create_muted_message
end
def unmute!
return unless contact
contact.update(blocked: false)
create_unmuted_message
end
def muted?
contact&.blocked? || false
end
end

View File

@@ -0,0 +1,71 @@
module Featurable
extend ActiveSupport::Concern
QUERY_MODE = {
flag_query_mode: :bit_operator,
check_for_column: false
}.freeze
FEATURE_LIST = YAML.safe_load(Rails.root.join('config/features.yml').read).freeze
FEATURES = FEATURE_LIST.each_with_object({}) do |feature, result|
result[result.keys.size + 1] = "feature_#{feature['name']}".to_sym
end
included do
include FlagShihTzu
has_flags FEATURES.merge(column: 'feature_flags').merge(QUERY_MODE)
before_create :enable_default_features
end
def enable_features(*names)
names.each do |name|
send("feature_#{name}=", true)
end
end
def enable_features!(*names)
enable_features(*names)
save
end
def disable_features(*names)
names.each do |name|
send("feature_#{name}=", false)
end
end
def disable_features!(*names)
disable_features(*names)
save
end
def feature_enabled?(name)
send("feature_#{name}?")
end
def all_features
FEATURE_LIST.pluck('name').index_with do |feature_name|
feature_enabled?(feature_name)
end
end
def enabled_features
all_features.select { |_feature, enabled| enabled == true }
end
def disabled_features
all_features.select { |_feature, enabled| enabled == false }
end
private
def enable_default_features
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
return true if config.blank?
features_to_enabled = config.value.select { |f| f[:enabled] }.pluck(:name)
enable_features(*features_to_enabled)
end
end

View File

@@ -0,0 +1,28 @@
module InboxAgentAvailability
extend ActiveSupport::Concern
def available_agents
online_agent_ids = fetch_online_agent_ids
return inbox_members.none if online_agent_ids.empty?
inbox_members
.joins(:user)
.where(users: { id: online_agent_ids })
.includes(:user)
end
def member_ids_with_assignment_capacity
member_ids
end
private
def fetch_online_agent_ids
OnlineStatusTracker.get_available_users(account_id)
.select { |_key, value| value.eql?('online') }
.keys
.map(&:to_i)
end
end
InboxAgentAvailability.prepend_mod_with('InboxAgentAvailability')

View File

@@ -0,0 +1,95 @@
# This file defines a custom validator class `JsonSchemaValidator` for validating a JSON object against a schema.
# To use this validator, define a schema as a Ruby hash and include it in the validation options when validating a model.
# The schema should define the expected structure and types of the JSON object, as well as any validation rules.
# Here's an example schema:
#
# schema = {
# 'type' => 'object',
# 'properties' => {
# 'name' => { 'type' => 'string' },
# 'age' => { 'type' => 'integer' },
# 'is_active' => { 'type' => 'boolean' },
# 'tags' => { 'type' => 'array' },
# 'address' => {
# 'type' => 'object',
# 'properties' => {
# 'street' => { 'type' => 'string' },
# 'city' => { 'type' => 'string' }
# },
# 'required' => ['street', 'city']
# }
# },
# 'required': ['name', 'age']
# }.to_json.freeze
#
# To validate a model using this schema, include the `JsonSchemaValidator` in the model's validations and pass the schema
# as an option:
#
# class MyModel < ApplicationRecord
# validates_with JsonSchemaValidator, schema: schema
# end
class JsonSchemaValidator < ActiveModel::Validator
def validate(record)
# Get the attribute resolver function from options or use a default one
attribute_resolver = options[:attribute_resolver] || ->(rec) { rec.additional_attributes }
# Resolve the JSON data to be validated
json_data = attribute_resolver.call(record)
# Get the schema to be used for validation
schema = options[:schema]
# Create a JSONSchemer instance using the schema
schemer = JSONSchemer.schema(schema)
# Validate the JSON data against the schema
validation_errors = schemer.validate(json_data)
# Add validation errors to the record with a formatted statement
validation_errors.each do |error|
format_and_append_error(error, record)
end
end
private
def format_and_append_error(error, record)
return handle_required(error, record) if error['type'] == 'required'
return handle_minimum(error, record) if error['type'] == 'minimum'
return handle_maximum(error, record) if error['type'] == 'maximum'
type = error['type'] == 'object' ? 'hash' : error['type']
handle_type(error, record, type)
end
def handle_required(error, record)
missing_values = error['details']['missing_keys']
missing_values.each do |missing|
record.errors.add(missing, 'is required')
end
end
def handle_type(error, record, expected_type)
data = get_name_from_data_pointer(error)
record.errors.add(data, "must be of type #{expected_type}")
end
def handle_minimum(error, record)
data = get_name_from_data_pointer(error)
record.errors.add(data, "must be greater than or equal to #{error['schema']['minimum']}")
end
def handle_maximum(error, record)
data = get_name_from_data_pointer(error)
record.errors.add(data, "must be less than or equal to #{error['schema']['maximum']}")
end
def get_name_from_data_pointer(error)
data = error['data_pointer']
# if data starts with a "/" remove it
data[1..] if data[0] == '/'
end
end

View File

@@ -0,0 +1,20 @@
module LabelActivityMessageHandler
extend ActiveSupport::Concern
private
def create_label_added(user_name, labels = [])
create_label_change_activity('added', user_name, labels)
end
def create_label_removed(user_name, labels = [])
create_label_change_activity('removed', user_name, labels)
end
def create_label_change_activity(change_type, user_name, labels = [])
return unless labels.size.positive?
content = I18n.t("conversations.activity.labels.#{change_type}", user_name: user_name, labels: labels.join(', '))
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
end

View File

@@ -0,0 +1,19 @@
module Labelable
extend ActiveSupport::Concern
included do
acts_as_taggable_on :labels
end
def update_labels(labels = nil)
update!(label_list: labels)
end
def add_labels(new_labels = nil)
return if new_labels.blank?
new_labels = Array(new_labels) # Make sure new_labels is an array
combined_labels = labels + new_labels
update!(label_list: combined_labels)
end
end

View File

@@ -0,0 +1,96 @@
module Liquidable
extend ActiveSupport::Concern
included do
before_create :process_liquid_in_content
before_create :process_liquid_in_template_params
end
private
def message_drops
{
'contact' => ContactDrop.new(conversation.contact),
'agent' => UserDrop.new(sender),
'conversation' => ConversationDrop.new(conversation),
'inbox' => InboxDrop.new(inbox),
'account' => AccountDrop.new(conversation.account)
}
end
def liquid_processable_message?
content.present? && (message_type == 'outgoing' || message_type == 'template')
end
def process_liquid_in_content
return unless liquid_processable_message?
template = Liquid::Template.parse(modified_liquid_content)
self.content = template.render(message_drops)
rescue Liquid::Error
# If there is an error in the liquid syntax, we don't want to process it
end
def modified_liquid_content
# This regex is used to match the code blocks in the content
# We don't want to process liquid in code blocks
content.gsub(/`(.*?)`/m, '{% raw %}`\\1`{% endraw %}')
end
def process_liquid_in_template_params
return unless template_params_present? && liquid_processable_template_params?
processed_params = process_liquid_in_hash(template_params_data['processed_params'])
# Update the additional_attributes with processed template_params
self.additional_attributes = additional_attributes.merge(
'template_params' => template_params_data.merge('processed_params' => processed_params)
)
rescue Liquid::Error
# If there is an error in the liquid syntax, we don't want to process it
end
def template_params_present?
additional_attributes&.dig('template_params', 'processed_params').present?
end
def liquid_processable_template_params?
message_type == 'outgoing' || message_type == 'template'
end
def template_params_data
additional_attributes['template_params']
end
def process_liquid_in_hash(hash)
return hash unless hash.is_a?(Hash)
hash.transform_values { |value| process_liquid_value(value) }
end
def process_liquid_value(value)
case value
when String
process_liquid_string(value)
when Hash
process_liquid_in_hash(value)
when Array
process_liquid_array(value)
else
value
end
end
def process_liquid_array(array)
array.map { |item| process_liquid_value(item) }
end
def process_liquid_string(string)
return string if string.blank?
template = Liquid::Template.parse(string)
template.render(message_drops)
rescue Liquid::Error
string
end
end

View File

@@ -0,0 +1,7 @@
module LlmFormattable
extend ActiveSupport::Concern
def to_llm_text(config = {})
LlmFormatter::LlmTextFormatterService.new(self).format(config)
end
end

View File

@@ -0,0 +1,31 @@
module MessageFilterHelpers
extend ActiveSupport::Concern
def reportable?
incoming? || outgoing?
end
def webhook_sendable?
incoming? || outgoing? || template?
end
def slack_hook_sendable?
incoming? || outgoing? || template?
end
def notifiable?
incoming? || outgoing?
end
def conversation_transcriptable?
incoming? || outgoing?
end
def email_reply_summarizable?
incoming? || outgoing? || input_csat?
end
def instagram_story_mention?
inbox.instagram? && try(:content_attributes)[:image_type] == 'story_mention'
end
end

View File

@@ -0,0 +1,53 @@
# frozen_string_literal: true
module OutOfOffisable
extend ActiveSupport::Concern
OFFISABLE_ATTRS = %w[day_of_week closed_all_day open_hour open_minutes close_hour close_minutes open_all_day].freeze
included do
has_many :working_hours, dependent: :destroy_async
after_create :create_default_working_hours
end
def out_of_office?
working_hours_enabled? && working_hours.today.closed_now?
end
def working_now?
!out_of_office?
end
def weekly_schedule
working_hours.order(day_of_week: :asc).select(*OFFISABLE_ATTRS).as_json(except: :id)
end
# accepts an array of hashes similiar to the format of weekly_schedule
# [
# { "day_of_week"=>1,
# "closed_all_day"=>false,
# "open_hour"=>9,
# "open_minutes"=>0,
# "close_hour"=>17,
# "close_minutes"=>0,
# "open_all_day=>false" },...]
def update_working_hours(params)
ActiveRecord::Base.transaction do
params.each do |working_hour|
working_hours.find_by(day_of_week: working_hour['day_of_week']).update(working_hour.slice(*OFFISABLE_ATTRS))
end
end
end
private
def create_default_working_hours
working_hours.create!(day_of_week: 0, closed_all_day: true, open_all_day: false)
working_hours.create!(day_of_week: 1, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
working_hours.create!(day_of_week: 2, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
working_hours.create!(day_of_week: 3, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
working_hours.create!(day_of_week: 4, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
working_hours.create!(day_of_week: 5, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
working_hours.create!(day_of_week: 6, closed_all_day: true, open_all_day: false)
end
end

View File

@@ -0,0 +1,33 @@
module PriorityActivityMessageHandler
extend ActiveSupport::Concern
private
def priority_change_activity(user_name)
old_priority, new_priority = previous_changes.values_at('priority')[0]
return unless priority_change?(old_priority, new_priority)
user = Current.executed_by.instance_of?(AutomationRule) ? I18n.t('automation.system_name') : user_name
content = build_priority_change_content(user, old_priority, new_priority)
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
def priority_change?(old_priority, new_priority)
old_priority.present? || new_priority.present?
end
def build_priority_change_content(user_name, old_priority = nil, new_priority = nil)
change_type = get_priority_change_type(old_priority, new_priority)
I18n.t("conversations.activity.priority.#{change_type}", user_name: user_name, new_priority: new_priority, old_priority: old_priority)
end
def get_priority_change_type(old_priority, new_priority)
case [old_priority.present?, new_priority.present?]
when [true, true] then 'updated'
when [false, true] then 'added'
when [true, false] then 'removed'
end
end
end

View File

@@ -0,0 +1,26 @@
# frozen_string_literal: true
module Pubsubable
extend ActiveSupport::Concern
included do
# Used by the actionCable/PubSub Service we use for real time communications
has_secure_token :pubsub_token
before_save :rotate_pubsub_token
end
def rotate_pubsub_token
# ATM we are only rotating the token if the user is changing their password
return unless is_a?(User)
# Using the class method to avoid the extra Save
# TODO: Should we do this on signin ?
self.pubsub_token = self.class.generate_unique_secure_token if will_save_change_to_encrypted_password?
end
def pubsub_token
# backfills tokens for existing records
regenerate_pubsub_token if self[:pubsub_token].blank? && persisted?
self[:pubsub_token]
end
end

View File

@@ -0,0 +1,15 @@
module PushDataHelper
extend ActiveSupport::Concern
def push_event_data
Conversations::EventDataPresenter.new(self).push_data
end
def lock_event_data
Conversations::EventDataPresenter.new(self).lock_data
end
def webhook_data
Conversations::EventDataPresenter.new(self).push_data
end
end

View File

@@ -0,0 +1,97 @@
# This concern is primarily targeted for business models dependent on external services
# The auth tokens we obtained on their behalf could expire or becomes invalid.
# We would be aware of it until we make the API call to the service and it throws error
# Example:
# when a user changes his/her password, the auth token they provided to chatwoot becomes invalid
# This module helps to capture the errors into a counter and when threshold is passed would mark
# the object to be reauthorized. We will also send an email to the owners alerting them of the error.
# In the UI, we will check for the reauthorization_required? status and prompt the reauthorization flow
module Reauthorizable
extend ActiveSupport::Concern
AUTHORIZATION_ERROR_THRESHOLD = 2
# model attribute
def reauthorization_required?
::Redis::Alfred.get(reauthorization_required_key).present?
end
# model attribute
def authorization_error_count
::Redis::Alfred.get(authorization_error_count_key).to_i
end
# action to be performed when we receive authorization errors
# Implement in your exception handling logic for authorization errors
def authorization_error!
::Redis::Alfred.incr(authorization_error_count_key)
# we are giving precendence to the authorization error threshhold defined in the class
# so that channels can override the default value
prompt_reauthorization! if authorization_error_count >= self.class::AUTHORIZATION_ERROR_THRESHOLD
end
# Performed automatically if error threshold is breached
# could used to manually prompt reauthorization if auth scope changes
def prompt_reauthorization!
::Redis::Alfred.set(reauthorization_required_key, true)
reauthorization_handlers[self.class.name]&.call(self)
invalidate_inbox_cache unless instance_of?(::AutomationRule)
end
def process_integration_hook_reauthorization_emails
if slack?
AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).slack_disconnect.deliver_later
elsif dialogflow?
AdministratorNotifications::IntegrationsNotificationMailer.with(account: account).dialogflow_disconnect.deliver_later
end
end
def send_channel_reauthorization_email(disconnect_type)
AdministratorNotifications::ChannelNotificationsMailer.with(account: account).public_send(disconnect_type, inbox).deliver_later
end
def handle_automation_rule_reauthorization
update!(active: false)
AdministratorNotifications::AccountNotificationMailer.with(account: account).automation_rule_disabled(self).deliver_later
end
# call this after you successfully Reauthorized the object in UI
def reauthorized!
::Redis::Alfred.delete(authorization_error_count_key)
::Redis::Alfred.delete(reauthorization_required_key)
invalidate_inbox_cache unless instance_of?(::AutomationRule)
end
private
def reauthorization_handlers
{
'Integrations::Hook' => ->(obj) { obj.process_integration_hook_reauthorization_emails },
'Channel::FacebookPage' => ->(obj) { obj.send_channel_reauthorization_email(:facebook_disconnect) },
'Channel::Instagram' => ->(obj) { obj.send_channel_reauthorization_email(:instagram_disconnect) },
'Channel::Tiktok' => ->(obj) { obj.send_channel_reauthorization_email(:tiktok_disconnect) },
'Channel::Whatsapp' => ->(obj) { obj.send_channel_reauthorization_email(:whatsapp_disconnect) },
'Channel::Email' => ->(obj) { obj.send_channel_reauthorization_email(:email_disconnect) },
'AutomationRule' => ->(obj) { obj.handle_automation_rule_reauthorization }
}
end
def invalidate_inbox_cache
inbox.update_account_cache if inbox.present?
end
def authorization_error_count_key
format(::Redis::Alfred::AUTHORIZATION_ERROR_COUNT, obj_type: self.class.table_name.singularize, obj_id: id)
end
def reauthorization_required_key
format(::Redis::Alfred::REAUTHORIZATION_REQUIRED, obj_type: self.class.table_name.singularize, obj_id: id)
end
end

View File

@@ -0,0 +1,9 @@
# frozen_string_literal: true
module Reportable
extend ActiveSupport::Concern
included do
has_many :reporting_events, dependent: :destroy
end
end

View File

@@ -0,0 +1,31 @@
module SlaActivityMessageHandler
extend ActiveSupport::Concern
private
def create_sla_change_activity(change_type, user_name)
content = case change_type
when 'added'
I18n.t('conversations.activity.sla.added', user_name: user_name, sla_name: sla_policy_name)
when 'removed'
I18n.t('conversations.activity.sla.removed', user_name: user_name, sla_name: sla_policy_name)
when 'updated'
I18n.t('conversations.activity.sla.updated', user_name: user_name, sla_name: sla_policy_name)
end
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
def sla_policy_name
SlaPolicy.find_by(id: sla_policy_id)&.name || ''
end
def determine_sla_change_type
sla_policy_id_before, sla_policy_id_after = previous_changes[:sla_policy_id]
if sla_policy_id_before.nil? && sla_policy_id_after.present?
'added'
elsif sla_policy_id_before.present? && sla_policy_id_after.nil?
'removed'
end
end
end

View File

@@ -0,0 +1,37 @@
module SortHandler
extend ActiveSupport::Concern
class_methods do
def sort_on_last_activity_at(sort_direction = :desc)
order(last_activity_at: sort_direction)
end
def sort_on_created_at(sort_direction = :asc)
order(created_at: sort_direction)
end
def sort_on_priority(sort_direction = :desc)
order(generate_sql_query("priority #{sort_direction.to_s.upcase} NULLS LAST, last_activity_at DESC"))
end
def sort_on_waiting_since(sort_direction = :asc)
order(generate_sql_query("waiting_since #{sort_direction.to_s.upcase} NULLS LAST, created_at ASC"))
end
def last_messaged_conversations
Message.except(:order).select(
'DISTINCT ON (conversation_id) conversation_id, id, created_at, message_type'
).order('conversation_id, created_at DESC')
end
def sort_on_last_user_message_at
order('grouped_conversations.message_type', 'grouped_conversations.created_at ASC')
end
private
def generate_sql_query(query)
Arel::Nodes::SqlLiteral.new(sanitize_sql_for_order(query))
end
end
end

View File

@@ -0,0 +1,32 @@
module SsoAuthenticatable
extend ActiveSupport::Concern
def generate_sso_auth_token
token = SecureRandom.hex(32)
::Redis::Alfred.setex(sso_token_key(token), true, 5.minutes)
token
end
def invalidate_sso_auth_token(token)
::Redis::Alfred.delete(sso_token_key(token))
end
def valid_sso_auth_token?(token)
::Redis::Alfred.get(sso_token_key(token)).present?
end
def generate_sso_link
encoded_email = ERB::Util.url_encode(email)
"#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{generate_sso_auth_token}"
end
def generate_sso_link_with_impersonation
"#{generate_sso_link}&impersonation=true"
end
private
def sso_token_key(token)
format(::Redis::RedisKeys::USER_SSO_AUTH_TOKEN, user_id: id, token: token)
end
end

View File

@@ -0,0 +1,29 @@
module TeamActivityMessageHandler
extend ActiveSupport::Concern
private
def create_team_change_activity(user_name)
user_name = activity_message_owner(user_name)
return unless user_name
key = generate_team_change_activity_key
params = { assignee_name: assignee&.name, team_name: team&.name, user_name: user_name }
params[:team_name] = generate_team_name_for_activity if key == 'removed'
content = I18n.t("conversations.activity.team.#{key}", **params)
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
def generate_team_change_activity_key
team = Team.find_by(id: team_id)
key = team.present? ? 'assigned' : 'removed'
key += '_with_assignee' if key == 'assigned' && saved_change_to_assignee_id? && assignee
key
end
def generate_team_name_for_activity
previous_team_id = previous_changes[:team_id][0]
Team.find_by(id: previous_team_id)&.name if previous_team_id.present?
end
end

View File

@@ -0,0 +1,53 @@
module UserAttributeHelpers
extend ActiveSupport::Concern
def available_name
self[:display_name].presence || name
end
def availability_status
current_account_user&.availability_status
end
def auto_offline
current_account_user&.auto_offline
end
def inviter
current_account_user&.inviter
end
def active_account_user
account_users.order(Arel.sql('active_at DESC NULLS LAST'))&.first
end
def current_account_user
# We want to avoid subsequent queries in case where the association is preloaded.
# using where here will trigger n+1 queries.
account_users.find { |ac_usr| ac_usr.account_id == Current.account.id } if Current.account
end
def account
current_account_user&.account
end
def administrator?
current_account_user&.administrator?
end
def agent?
current_account_user&.agent?
end
def role
current_account_user&.role
end
# Used internally for Chatwoot in Chatwoot
def hmac_identifier
hmac_key = GlobalConfig.get('CHATWOOT_INBOX_HMAC_KEY')['CHATWOOT_INBOX_HMAC_KEY']
return OpenSSL::HMAC.hexdigest('sha256', hmac_key, email) if hmac_key.present?
''
end
end

View File

@@ -0,0 +1,253 @@
# rubocop:disable Layout/LineLength
# == Schema Information
#
# Table name: contacts
#
# id :integer not null, primary key
# additional_attributes :jsonb
# blocked :boolean default(FALSE), not null
# contact_type :integer default("visitor")
# country_code :string default("")
# custom_attributes :jsonb
# email :string
# identifier :string
# last_activity_at :datetime
# last_name :string default("")
# location :string default("")
# middle_name :string default("")
# name :string default("")
# phone_number :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# company_id :bigint
#
# Indexes
#
# index_contacts_on_account_id (account_id)
# index_contacts_on_account_id_and_contact_type (account_id,contact_type)
# index_contacts_on_account_id_and_last_activity_at (account_id,last_activity_at DESC NULLS LAST)
# index_contacts_on_blocked (blocked)
# index_contacts_on_company_id (company_id)
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
# index_contacts_on_name_email_phone_number_identifier (name,email,phone_number,identifier) USING gin
# index_contacts_on_nonempty_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))
# index_contacts_on_phone_number_and_account_id (phone_number,account_id)
# index_resolved_contact_account_id (account_id) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))
# uniq_email_per_account_contact (email,account_id) UNIQUE
# uniq_identifier_per_account_contact (identifier,account_id) UNIQUE
#
# rubocop:enable Layout/LineLength
class Contact < ApplicationRecord
include Avatarable
include AvailabilityStatusable
include Labelable
include LlmFormattable
validates :account_id, presence: true
validates :email, allow_blank: true, uniqueness: { scope: [:account_id], case_sensitive: false },
format: { with: Devise.email_regexp, message: I18n.t('errors.contacts.email.invalid') }
validates :identifier, allow_blank: true, uniqueness: { scope: [:account_id] }
validates :phone_number,
allow_blank: true, uniqueness: { scope: [:account_id] },
format: { with: /\+[1-9]\d{1,14}\z/, message: I18n.t('errors.contacts.phone_number.invalid') }
belongs_to :account
has_many :conversations, dependent: :destroy_async
has_many :contact_inboxes, dependent: :destroy_async
has_many :csat_survey_responses, dependent: :destroy_async
has_many :inboxes, through: :contact_inboxes
has_many :messages, as: :sender, dependent: :destroy_async
has_many :notes, dependent: :destroy_async
before_validation :prepare_contact_attributes
after_create_commit :dispatch_create_event, :ip_lookup
after_update_commit :dispatch_update_event
after_destroy_commit :dispatch_destroy_event
before_save :sync_contact_attributes
enum contact_type: { visitor: 0, lead: 1, customer: 2 }
scope :order_on_last_activity_at, lambda { |direction|
order(
Arel::Nodes::SqlLiteral.new(
sanitize_sql_for_order("\"contacts\".\"last_activity_at\" #{direction}
NULLS LAST")
)
)
}
scope :order_on_created_at, lambda { |direction|
order(
Arel::Nodes::SqlLiteral.new(
sanitize_sql_for_order("\"contacts\".\"created_at\" #{direction}
NULLS LAST")
)
)
}
scope :order_on_company_name, lambda { |direction|
order(
Arel::Nodes::SqlLiteral.new(
sanitize_sql_for_order(
"\"contacts\".\"additional_attributes\"->>'company_name' #{direction}
NULLS LAST"
)
)
)
}
scope :order_on_city, lambda { |direction|
order(
Arel::Nodes::SqlLiteral.new(
sanitize_sql_for_order(
"\"contacts\".\"additional_attributes\"->>'city' #{direction}
NULLS LAST"
)
)
)
}
scope :order_on_country_name, lambda { |direction|
order(
Arel::Nodes::SqlLiteral.new(
sanitize_sql_for_order(
"\"contacts\".\"additional_attributes\"->>'country' #{direction}
NULLS LAST"
)
)
)
}
scope :order_on_name, lambda { |direction|
order(
Arel::Nodes::SqlLiteral.new(
sanitize_sql_for_order(
"CASE
WHEN \"contacts\".\"name\" ~~* '^+\d*' THEN 'z'
WHEN \"contacts\".\"name\" ~~* '^\b*' THEN 'z'
ELSE LOWER(\"contacts\".\"name\")
END #{direction}"
)
)
)
}
# Find contacts that:
# 1. Have no identification (email, phone_number, and identifier are NULL or empty string)
# 2. Have no conversations
# 3. Are older than the specified time period
scope :stale_without_conversations, lambda { |time_period|
where('contacts.email IS NULL OR contacts.email = ?', '')
.where('contacts.phone_number IS NULL OR contacts.phone_number = ?', '')
.where('contacts.identifier IS NULL OR contacts.identifier = ?', '')
.where('contacts.created_at < ?', time_period)
.where.missing(:conversations)
}
def get_source_id(inbox_id)
contact_inboxes.find_by!(inbox_id: inbox_id).source_id
end
def push_event_data
{
additional_attributes: additional_attributes,
custom_attributes: custom_attributes,
email: email,
id: id,
identifier: identifier,
name: name,
phone_number: phone_number,
thumbnail: avatar_url,
blocked: blocked,
type: 'contact'
}
end
def webhook_data
{
account: account.webhook_data,
additional_attributes: additional_attributes,
avatar: avatar_url,
custom_attributes: custom_attributes,
email: email,
id: id,
identifier: identifier,
name: name,
phone_number: phone_number,
thumbnail: avatar_url,
blocked: blocked
}
end
def self.resolved_contacts(use_crm_v2: false)
return where(contact_type: 'lead') if use_crm_v2
where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> ''")
end
def discard_invalid_attrs
phone_number_format
email_format
end
def self.from_email(email)
find_by(email: email&.downcase)
end
private
def ip_lookup
return unless account.feature_enabled?('ip_lookup')
ContactIpLookupJob.perform_later(self)
end
def phone_number_format
return if phone_number.blank?
self.phone_number = phone_number_was unless phone_number.match?(/\+[1-9]\d{1,14}\z/)
end
def email_format
return if email.blank?
self.email = email_was unless email.match(Devise.email_regexp)
end
def prepare_contact_attributes
prepare_email_attribute
prepare_jsonb_attributes
end
def prepare_email_attribute
# So that the db unique constraint won't throw error when email is ''
self.email = email.present? ? email.downcase : nil
end
def prepare_jsonb_attributes
self.additional_attributes = {} if additional_attributes.blank?
self.custom_attributes = {} if custom_attributes.blank?
end
def sync_contact_attributes
::Contacts::SyncAttributes.new(self).perform
end
def dispatch_create_event
Rails.configuration.dispatcher.dispatch(CONTACT_CREATED, Time.zone.now, contact: self)
end
def dispatch_update_event
Rails.configuration.dispatcher.dispatch(CONTACT_UPDATED, Time.zone.now, contact: self, changed_attributes: previous_changes)
end
def dispatch_destroy_event
# Pass serialized data instead of ActiveRecord object to avoid DeserializationError
# when the async EventDispatcherJob runs after the contact has been deleted
Rails.configuration.dispatcher.dispatch(
CONTACT_DELETED,
Time.zone.now,
contact_data: push_event_data.merge(account_id: account_id)
)
end
end
Contact.include_mod_with('Concerns::Contact')

View File

@@ -0,0 +1,79 @@
# == Schema Information
#
# Table name: contact_inboxes
#
# id :bigint not null, primary key
# hmac_verified :boolean default(FALSE)
# pubsub_token :string
# created_at :datetime not null
# updated_at :datetime not null
# contact_id :bigint
# inbox_id :bigint
# source_id :text not null
#
# Indexes
#
# index_contact_inboxes_on_contact_id (contact_id)
# index_contact_inboxes_on_inbox_id (inbox_id)
# index_contact_inboxes_on_inbox_id_and_source_id (inbox_id,source_id) UNIQUE
# index_contact_inboxes_on_pubsub_token (pubsub_token) UNIQUE
# index_contact_inboxes_on_source_id (source_id)
#
class ContactInbox < ApplicationRecord
include Pubsubable
include RegexHelper
validates :inbox_id, presence: true
validates :contact_id, presence: true
validates :source_id, presence: true
validate :valid_source_id_format?
belongs_to :contact
belongs_to :inbox
has_many :conversations, dependent: :destroy_async
# contact_inboxes that are not associated with any conversation
scope :stale_without_conversations, lambda { |time_period|
left_joins(:conversations)
.where('contact_inboxes.created_at < ?', time_period)
.where(conversations: { contact_id: nil })
}
def webhook_data
{
id: id,
contact: contact.try(:webhook_data),
inbox: inbox.webhook_data,
account: inbox.account.webhook_data,
current_conversation: current_conversation.try(:webhook_data),
source_id: source_id
}
end
def current_conversation
conversations.last
end
private
def validate_twilio_source_id
# https://www.twilio.com/docs/glossary/what-e164#regex-matching-for-e164
if inbox.channel.medium == 'sms' && !TWILIO_CHANNEL_SMS_REGEX.match?(source_id)
errors.add(:source_id, "invalid source id for twilio sms inbox. valid Regex #{TWILIO_CHANNEL_SMS_REGEX}")
elsif inbox.channel.medium == 'whatsapp' && !TWILIO_CHANNEL_WHATSAPP_REGEX.match?(source_id)
errors.add(:source_id, "invalid source id for twilio whatsapp inbox. valid Regex #{TWILIO_CHANNEL_WHATSAPP_REGEX}")
end
end
def validate_whatsapp_source_id
return if WHATSAPP_CHANNEL_REGEX.match?(source_id)
errors.add(:source_id, "invalid source id for whatsapp inbox. valid Regex #{WHATSAPP_CHANNEL_REGEX}")
end
def valid_source_id_format?
validate_twilio_source_id if inbox.channel_type == 'Channel::TwilioSms'
validate_whatsapp_source_id if inbox.channel_type == 'Channel::Whatsapp'
end
end

View File

@@ -0,0 +1,347 @@
# == Schema Information
#
# Table name: conversations
#
# id :integer not null, primary key
# additional_attributes :jsonb
# agent_last_seen_at :datetime
# assignee_last_seen_at :datetime
# cached_label_list :text
# contact_last_seen_at :datetime
# custom_attributes :jsonb
# first_reply_created_at :datetime
# identifier :string
# last_activity_at :datetime not null
# priority :integer
# snoozed_until :datetime
# status :integer default("open"), not null
# uuid :uuid not null
# waiting_since :datetime
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# assignee_agent_bot_id :bigint
# assignee_id :integer
# campaign_id :bigint
# contact_id :bigint
# contact_inbox_id :bigint
# display_id :integer not null
# inbox_id :integer not null
# sla_policy_id :bigint
# team_id :bigint
#
# Indexes
#
# conv_acid_inbid_stat_asgnid_idx (account_id,inbox_id,status,assignee_id)
# index_conversations_on_account_id (account_id)
# index_conversations_on_account_id_and_display_id (account_id,display_id) UNIQUE
# index_conversations_on_assignee_id_and_account_id (assignee_id,account_id)
# index_conversations_on_campaign_id (campaign_id)
# index_conversations_on_contact_id (contact_id)
# index_conversations_on_contact_inbox_id (contact_inbox_id)
# index_conversations_on_first_reply_created_at (first_reply_created_at)
# index_conversations_on_id_and_account_id (account_id,id)
# index_conversations_on_identifier_and_account_id (identifier,account_id)
# index_conversations_on_inbox_id (inbox_id)
# index_conversations_on_priority (priority)
# index_conversations_on_status_and_account_id (status,account_id)
# index_conversations_on_status_and_priority (status,priority)
# index_conversations_on_team_id (team_id)
# index_conversations_on_uuid (uuid) UNIQUE
# index_conversations_on_waiting_since (waiting_since)
#
class Conversation < ApplicationRecord
include Labelable
include LlmFormattable
include AssignmentHandler
include AutoAssignmentHandler
include ActivityMessageHandler
include UrlHelper
include SortHandler
include PushDataHelper
include ConversationMuteHelpers
validates :account_id, presence: true
validates :inbox_id, presence: true
validates :contact_id, presence: true
before_validation :validate_additional_attributes
before_validation :reset_agent_bot_when_assignee_present
validates :additional_attributes, jsonb_attributes_length: true
validates :custom_attributes, jsonb_attributes_length: true
validates :uuid, uniqueness: true
validate :validate_referer_url
enum status: { open: 0, resolved: 1, pending: 2, snoozed: 3 }
enum priority: { low: 0, medium: 1, high: 2, urgent: 3 }
scope :unassigned, -> { where(assignee_id: nil) }
scope :assigned, -> { where.not(assignee_id: nil) }
scope :assigned_to, ->(agent) { where(assignee_id: agent.id) }
scope :unattended, -> { where(first_reply_created_at: nil).or(where.not(waiting_since: nil)) }
scope :resolvable_not_waiting, lambda { |auto_resolve_after|
return none if auto_resolve_after.to_i.zero?
open.where('last_activity_at < ? AND waiting_since IS NULL', Time.now.utc - auto_resolve_after.minutes)
}
scope :resolvable_all, lambda { |auto_resolve_after|
return none if auto_resolve_after.to_i.zero?
open.where('last_activity_at < ?', Time.now.utc - auto_resolve_after.minutes)
}
scope :last_user_message_at, lambda {
joins(
"INNER JOIN (#{last_messaged_conversations.to_sql}) AS grouped_conversations
ON grouped_conversations.conversation_id = conversations.id"
).sort_on_last_user_message_at
}
belongs_to :account
belongs_to :inbox
belongs_to :assignee, class_name: 'User', optional: true, inverse_of: :assigned_conversations
belongs_to :assignee_agent_bot, class_name: 'AgentBot', optional: true
belongs_to :contact
belongs_to :contact_inbox
belongs_to :team, optional: true
belongs_to :campaign, optional: true
has_many :mentions, dependent: :destroy_async
has_many :messages, dependent: :destroy_async, autosave: true
has_one :csat_survey_response, dependent: :destroy_async
has_many :conversation_participants, dependent: :destroy_async
has_many :notifications, as: :primary_actor, dependent: :destroy_async
has_many :attachments, through: :messages
has_many :reporting_events, dependent: :destroy_async
before_save :ensure_snooze_until_reset
before_create :determine_conversation_status
before_create :ensure_waiting_since
after_update_commit :execute_after_update_commit_callbacks
after_create_commit :notify_conversation_creation
after_create_commit :load_attributes_created_by_db_triggers
delegate :auto_resolve_after, to: :account
def can_reply?
Conversations::MessageWindowService.new(self).can_reply?
end
def language
additional_attributes&.dig('conversation_language')
end
# Be aware: The precision of created_at and last_activity_at may differ from Ruby's Time precision.
# Our DB column (see schema) stores timestamps with second-level precision (no microseconds), so
# if you assign a Ruby Time with microseconds, the DB will truncate it. This may cause subtle differences
# if you compare or copy these values in Ruby, also in our specs
# So in specs rely on to be_with(1.second) instead of to eq()
# TODO: Migrate to use a timestamp with microsecond precision
def last_activity_at
self[:last_activity_at] || created_at
end
def last_incoming_message
messages&.incoming&.last
end
def toggle_status
# FIXME: implement state machine with aasm
self.status = open? ? :resolved : :open
self.status = :open if pending? || snoozed?
save
end
def toggle_priority(priority = nil)
self.priority = priority.presence
save
end
def bot_handoff!
open!
dispatcher_dispatch(CONVERSATION_BOT_HANDOFF)
end
def unread_messages
agent_last_seen_at.present? ? messages.created_since(agent_last_seen_at) : messages
end
def assignee_unread_messages
assignee_last_seen_at.present? ? messages.created_since(assignee_last_seen_at) : messages
end
def unread_incoming_messages
unread_messages.where(account_id: account_id).incoming.last(10)
end
def cached_label_list_array
(cached_label_list || '').split(',').map(&:strip)
end
def notifiable_assignee_change?
return false unless saved_change_to_assignee_id?
return false if assignee_id.blank?
return false if self_assign?(assignee_id)
true
end
# Virtual attribute till we switch completely to polymorphic assignee
def assignee_type
return 'AgentBot' if assignee_agent_bot_id.present?
return 'User' if assignee_id.present?
nil
end
def assigned_entity
assignee_agent_bot || assignee
end
def tweet?
inbox.inbox_type == 'Twitter' && additional_attributes['type'] == 'tweet'
end
def recent_messages
messages.chat.last(5)
end
def csat_survey_link
"#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{uuid}"
end
def dispatch_conversation_updated_event(previous_changes = nil)
dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes)
end
private
def execute_after_update_commit_callbacks
handle_resolved_status_change
notify_status_change
create_activity
notify_conversation_updation
end
def handle_resolved_status_change
# When conversation is resolved, clear waiting_since using update_column to avoid callbacks
return unless saved_change_to_status? && status == 'resolved'
# rubocop:disable Rails/SkipsModelValidations
update_column(:waiting_since, nil)
# rubocop:enable Rails/SkipsModelValidations
end
def ensure_snooze_until_reset
self.snoozed_until = nil unless snoozed?
end
def ensure_waiting_since
self.waiting_since = created_at
end
def validate_additional_attributes
self.additional_attributes = {} unless additional_attributes.is_a?(Hash)
end
def reset_agent_bot_when_assignee_present
return if assignee_id.blank?
self.assignee_agent_bot_id = nil
end
def determine_conversation_status
self.status = :resolved and return if contact.blocked?
return handle_campaign_status if campaign.present?
# TODO: make this an inbox config instead of assuming bot conversations should start as pending
self.status = :pending if inbox.active_bot?
end
def handle_campaign_status
# If campaign has no sender (bot-initiated) and inbox has active bot, let bot handle it
self.status = :pending if campaign.sender_id.nil? && inbox.active_bot?
end
def notify_conversation_creation
dispatcher_dispatch(CONVERSATION_CREATED)
end
def notify_conversation_updation
return unless previous_changes.keys.present? && allowed_keys?
dispatch_conversation_updated_event(previous_changes)
end
def list_of_keys
%w[team_id assignee_id assignee_agent_bot_id status snoozed_until custom_attributes label_list waiting_since
first_reply_created_at priority]
end
def allowed_keys?
(
previous_changes.keys.intersect?(list_of_keys) ||
(previous_changes['additional_attributes'].present? && previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language]))
)
end
def load_attributes_created_by_db_triggers
# Display id is set via a trigger in the database
# So we need to specifically fetch it after the record is created
# We can't use reload because it will clear the previous changes, which we need for the dispatcher
obj_from_db = self.class.find(id)
self[:display_id] = obj_from_db[:display_id]
self[:uuid] = obj_from_db[:uuid]
end
def notify_status_change
{
CONVERSATION_OPENED => -> { saved_change_to_status? && open? },
CONVERSATION_RESOLVED => -> { saved_change_to_status? && resolved? },
CONVERSATION_STATUS_CHANGED => -> { saved_change_to_status? },
CONVERSATION_READ => -> { saved_change_to_contact_last_seen_at? },
CONVERSATION_CONTACT_CHANGED => -> { saved_change_to_contact_id? }
}.each do |event, condition|
condition.call && dispatcher_dispatch(event, status_change)
end
end
def dispatcher_dispatch(event_name, changed_attributes = nil)
Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, conversation: self, notifiable_assignee_change: notifiable_assignee_change?,
changed_attributes: changed_attributes,
performed_by: Current.executed_by)
end
def conversation_status_changed_to_open?
return false unless open?
# saved_change_to_status? method only works in case of update
return true if previous_changes.key?(:id) || saved_change_to_status?
end
def create_label_change(user_name)
return unless user_name
previous_labels, current_labels = previous_changes[:label_list]
return unless (previous_labels.is_a? Array) && (current_labels.is_a? Array)
create_label_added(user_name, current_labels - previous_labels)
create_label_removed(user_name, previous_labels - current_labels)
end
def validate_referer_url
return unless additional_attributes['referer']
self['additional_attributes']['referer'] = nil unless url_valid?(additional_attributes['referer'])
end
# creating db triggers
trigger.before(:insert).for_each(:row) do
"NEW.display_id := nextval('conv_dpid_seq_' || NEW.account_id);"
end
end
Conversation.include_mod_with('Audit::Conversation')
Conversation.include_mod_with('Concerns::Conversation')
Conversation.prepend_mod_with('Conversation')

View File

@@ -0,0 +1,41 @@
# == Schema Information
#
# Table name: conversation_participants
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# conversation_id :bigint not null
# user_id :bigint not null
#
# Indexes
#
# index_conversation_participants_on_account_id (account_id)
# index_conversation_participants_on_conversation_id (conversation_id)
# index_conversation_participants_on_user_id (user_id)
# index_conversation_participants_on_user_id_and_conversation_id (user_id,conversation_id) UNIQUE
#
class ConversationParticipant < ApplicationRecord
validates :account_id, presence: true
validates :conversation_id, presence: true
validates :user_id, presence: true
validates :user_id, uniqueness: { scope: [:conversation_id] }
validate :ensure_inbox_access
belongs_to :account
belongs_to :conversation
belongs_to :user
before_validation :ensure_account_id
private
def ensure_account_id
self.account_id = conversation&.account_id
end
def ensure_inbox_access
errors.add(:user, 'must have inbox access') if conversation && conversation.inbox.assignable_agents.exclude?(user)
end
end

View File

@@ -0,0 +1,43 @@
# == Schema Information
#
# Table name: csat_survey_responses
#
# id :bigint not null, primary key
# feedback_message :text
# rating :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assigned_agent_id :bigint
# contact_id :bigint not null
# conversation_id :bigint not null
# message_id :bigint not null
#
# Indexes
#
# index_csat_survey_responses_on_account_id (account_id)
# index_csat_survey_responses_on_assigned_agent_id (assigned_agent_id)
# index_csat_survey_responses_on_contact_id (contact_id)
# index_csat_survey_responses_on_conversation_id (conversation_id)
# index_csat_survey_responses_on_message_id (message_id) UNIQUE
#
class CsatSurveyResponse < ApplicationRecord
belongs_to :account
belongs_to :conversation
belongs_to :contact
belongs_to :message
belongs_to :assigned_agent, class_name: 'User', optional: true, inverse_of: :csat_survey_responses
belongs_to :review_notes_updated_by, class_name: 'User', optional: true
validates :rating, presence: true, inclusion: { in: [1, 2, 3, 4, 5] }
validates :account_id, presence: true
validates :contact_id, presence: true
validates :conversation_id, presence: true
scope :filter_by_created_at, ->(range) { where(created_at: range) if range.present? }
scope :filter_by_assigned_agent_id, ->(user_ids) { where(assigned_agent_id: user_ids) if user_ids.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? }
# filter by rating value
scope :filter_by_rating, ->(rating) { where(rating: rating) if rating.present? }
end

View File

@@ -0,0 +1,67 @@
# == Schema Information
#
# Table name: custom_attribute_definitions
#
# id :bigint not null, primary key
# attribute_description :text
# attribute_display_name :string
# attribute_display_type :integer default("text")
# attribute_key :string
# attribute_model :integer default("conversation_attribute")
# attribute_values :jsonb
# default_value :integer
# regex_cue :string
# regex_pattern :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint
#
# Indexes
#
# attribute_key_model_index (attribute_key,attribute_model,account_id) UNIQUE
# index_custom_attribute_definitions_on_account_id (account_id)
#
class CustomAttributeDefinition < ApplicationRecord
STANDARD_ATTRIBUTES = {
:conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at
last_activity_at],
:contact => %w[name email phone_number identifier country_code city created_at last_activity_at referer blocked]
}.freeze
scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) }
validates :attribute_display_name, presence: true
validates :attribute_key,
presence: true,
uniqueness: { scope: [:account_id, :attribute_model] }
validates :attribute_display_type, presence: true
validates :attribute_model, presence: true
validate :attribute_must_not_conflict, on: :create
enum attribute_model: { conversation_attribute: 0, contact_attribute: 1 }
enum attribute_display_type: { text: 0, number: 1, currency: 2, percent: 3, link: 4, date: 5, list: 6, checkbox: 7 }
belongs_to :account
after_update :update_widget_pre_chat_custom_fields
after_destroy :sync_widget_pre_chat_custom_fields
private
def sync_widget_pre_chat_custom_fields
::Inboxes::SyncWidgetPreChatCustomFieldsJob.perform_later(account, attribute_key)
end
def update_widget_pre_chat_custom_fields
::Inboxes::UpdateWidgetPreChatCustomFieldsJob.perform_later(account, self)
end
def attribute_must_not_conflict
model_keys = attribute_model.to_sym == :conversation_attribute ? :conversation : :contact
return unless attribute_key.in?(STANDARD_ATTRIBUTES[model_keys])
errors.add(:attribute_key, I18n.t('errors.custom_attribute_definition.key_conflict'))
end
end
CustomAttributeDefinition.include_mod_with('Concerns::CustomAttributeDefinition')

View File

@@ -0,0 +1,31 @@
# == Schema Information
#
# Table name: custom_filters
#
# id :bigint not null, primary key
# filter_type :integer default("conversation"), not null
# name :string not null
# query :jsonb not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# user_id :bigint not null
#
# Indexes
#
# index_custom_filters_on_account_id (account_id)
# index_custom_filters_on_user_id (user_id)
#
class CustomFilter < ApplicationRecord
belongs_to :user
belongs_to :account
enum filter_type: { conversation: 0, contact: 1, report: 2 }
validate :validate_number_of_filters
def validate_number_of_filters
return true if account.custom_filters.where(user_id: user_id).size < Limits::MAX_CUSTOM_FILTERS_PER_USER
errors.add :account_id, I18n.t('errors.custom_filters.number_of_records')
end
end

View File

@@ -0,0 +1,47 @@
# == Schema Information
#
# Table name: dashboard_apps
#
# id :bigint not null, primary key
# content :jsonb
# title :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# user_id :bigint
#
# Indexes
#
# index_dashboard_apps_on_account_id (account_id)
# index_dashboard_apps_on_user_id (user_id)
#
class DashboardApp < ApplicationRecord
belongs_to :user
belongs_to :account
validate :validate_content
private
def validate_content
has_invalid_data = self[:content].blank? || !self[:content].is_a?(Array)
self[:content] = [] if has_invalid_data
content_schema = {
'type' => 'array',
'items' => {
'type' => 'object',
'required' => %w[url type],
'properties' => {
'type' => { 'enum': ['frame'] },
'url' => { '$ref' => '#/definitions/saneUrl' }
}
},
'definitions' => {
'saneUrl' => { 'format' => 'uri', 'pattern' => '^https?://' }
},
'additionalProperties' => false,
'minItems' => 1
}
errors.add(:content, ': Invalid data') unless JSONSchemer.schema(content_schema.to_json).valid?(self[:content])
end
end

View File

@@ -0,0 +1,35 @@
# == Schema Information
#
# Table name: data_imports
#
# id :bigint not null, primary key
# data_type :string not null
# processed_records :integer
# processing_errors :text
# status :integer default("pending"), not null
# total_records :integer
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_data_imports_on_account_id (account_id)
#
class DataImport < ApplicationRecord
belongs_to :account
validates :data_type, inclusion: { in: ['contacts'], message: I18n.t('errors.data_import.data_type.invalid') }
enum status: { pending: 0, processing: 1, completed: 2, failed: 3 }
has_one_attached :import_file
has_one_attached :failed_records
after_create_commit :process_data_import
private
def process_data_import
# we wait for the file to be uploaded to the cloud
DataImportJob.set(wait: 1.minute).perform_later(self)
end
end

View File

@@ -0,0 +1,28 @@
# == Schema Information
#
# Table name: email_templates
#
# id :bigint not null, primary key
# body :text not null
# locale :integer default("en"), not null
# name :string not null
# template_type :integer default("content")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer
#
# Indexes
#
# index_email_templates_on_name_and_account_id (name,account_id) UNIQUE
#
class EmailTemplate < ApplicationRecord
enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true
enum :template_type, { layout: 0, content: 1 }
belongs_to :account, optional: true
validates :name, uniqueness: { scope: :account }
def self.resolver(options = {})
::EmailTemplates::DbResolverService.using self, options
end
end

View File

@@ -0,0 +1,20 @@
# == Schema Information
#
# Table name: folders
#
# id :bigint not null, primary key
# name :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# category_id :integer not null
#
class Folder < ApplicationRecord
belongs_to :account
belongs_to :category
has_many :articles, dependent: :nullify
validates :account_id, presence: true
validates :category_id, presence: true
validates :name, presence: true
end

View File

@@ -0,0 +1,251 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: inboxes
#
# id :integer not null, primary key
# allow_messages_after_resolved :boolean default(TRUE)
# auto_assignment_config :jsonb
# business_name :string
# channel_type :string
# csat_config :jsonb not null
# csat_survey_enabled :boolean default(FALSE)
# email_address :string
# enable_auto_assignment :boolean default(TRUE)
# enable_email_collect :boolean default(TRUE)
# greeting_enabled :boolean default(FALSE)
# greeting_message :string
# lock_to_single_conversation :boolean default(FALSE), not null
# name :string not null
# out_of_office_message :string
# sender_name_type :integer default("friendly"), not null
# timezone :string default("UTC")
# working_hours_enabled :boolean default(FALSE)
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# channel_id :integer not null
# portal_id :bigint
#
# Indexes
#
# index_inboxes_on_account_id (account_id)
# index_inboxes_on_channel_id_and_channel_type (channel_id,channel_type)
# index_inboxes_on_portal_id (portal_id)
#
# Foreign Keys
#
# fk_rails_... (portal_id => portals.id)
#
class Inbox < ApplicationRecord
include Reportable
include Avatarable
include OutOfOffisable
include AccountCacheRevalidator
include InboxAgentAvailability
# Not allowing characters:
validates :name, presence: true
validates :account_id, presence: true
validates :timezone, inclusion: { in: TZInfo::Timezone.all_identifiers }
validates :out_of_office_message, length: { maximum: Limits::OUT_OF_OFFICE_MESSAGE_MAX_LENGTH }
validates :greeting_message, length: { maximum: Limits::GREETING_MESSAGE_MAX_LENGTH }
validate :ensure_valid_max_assignment_limit
belongs_to :account
belongs_to :portal, optional: true
belongs_to :channel, polymorphic: true, dependent: :destroy
has_many :campaigns, dependent: :destroy_async
has_many :contact_inboxes, dependent: :destroy_async
has_many :contacts, through: :contact_inboxes
has_many :inbox_members, dependent: :destroy_async
has_many :members, through: :inbox_members, source: :user
has_many :conversations, dependent: :destroy_async
has_many :messages, dependent: :destroy_async
has_one :inbox_assignment_policy, dependent: :destroy
has_one :assignment_policy, through: :inbox_assignment_policy
has_one :agent_bot_inbox, dependent: :destroy_async
has_one :agent_bot, through: :agent_bot_inbox
has_many :webhooks, dependent: :destroy_async
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
enum sender_name_type: { friendly: 0, professional: 1 }
after_destroy :delete_round_robin_agents
after_create_commit :dispatch_create_event
after_update_commit :dispatch_update_event
scope :order_by_name, -> { order('lower(name) ASC') }
# Adds multiple members to the inbox
# @param user_ids [Array<Integer>] Array of user IDs to add as members
# @return [void]
def add_members(user_ids)
inbox_members.create!(user_ids.map { |user_id| { user_id: user_id } })
update_account_cache
end
# Removes multiple members from the inbox
# @param user_ids [Array<Integer>] Array of user IDs to remove
# @return [void]
def remove_members(user_ids)
inbox_members.where(user_id: user_ids).destroy_all
update_account_cache
end
# Sanitizes inbox name for balanced email provider compatibility
# ALLOWS: /'._- and Unicode letters/numbers/emojis
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
def sanitized_name
return default_name_for_blank_name if name.blank?
sanitized = apply_sanitization_rules(name)
sanitized.blank? && email? ? display_name_from_email : sanitized
end
def sms?
channel_type == 'Channel::Sms'
end
def facebook?
channel_type == 'Channel::FacebookPage'
end
def instagram?
(facebook? || instagram_direct?) && channel.instagram_id.present?
end
def instagram_direct?
channel_type == 'Channel::Instagram'
end
def tiktok?
channel_type == 'Channel::Tiktok'
end
def web_widget?
channel_type == 'Channel::WebWidget'
end
def api?
channel_type == 'Channel::Api'
end
def email?
channel_type == 'Channel::Email'
end
def twilio?
channel_type == 'Channel::TwilioSms'
end
def twitter?
channel_type == 'Channel::TwitterProfile'
end
def telegram?
channel_type == 'Channel::Telegram'
end
def whatsapp?
channel_type == 'Channel::Whatsapp'
end
def twilio_whatsapp?
channel_type == 'Channel::TwilioSms' && channel.medium == 'whatsapp'
end
def assignable_agents
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
end
def active_bot?
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
status: 'enabled').count.positive?
end
def inbox_type
channel.name
end
def webhook_data
{
id: id,
name: name
}
end
def callback_webhook_url
case channel_type
when 'Channel::TwilioSms'
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback"
when 'Channel::Sms'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
when 'Channel::Line'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}"
when 'Channel::Whatsapp'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
end
end
def member_ids_with_assignment_capacity
members.ids
end
def auto_assignment_v2_enabled?
account.feature_enabled?('assignment_v2')
end
private
def default_name_for_blank_name
email? ? display_name_from_email : ''
end
def apply_sanitization_rules(name)
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;]/, '') # Remove forbidden chars
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
.gsub(/\s+/, ' ') # Normalize spaces
.strip
end
def display_name_from_email
channel.email.split('@').first.parameterize.titleize
end
def dispatch_create_event
return if ENV['ENABLE_INBOX_EVENTS'].blank?
Rails.configuration.dispatcher.dispatch(INBOX_CREATED, Time.zone.now, inbox: self)
end
def dispatch_update_event
return if ENV['ENABLE_INBOX_EVENTS'].blank?
Rails.configuration.dispatcher.dispatch(INBOX_UPDATED, Time.zone.now, inbox: self, changed_attributes: previous_changes)
end
def ensure_valid_max_assignment_limit
# overridden in enterprise/app/models/enterprise/inbox.rb
end
def delete_round_robin_agents
::AutoAssignment::InboxRoundRobinService.new(inbox: self).clear_queue
end
def check_channel_type?
['Channel::Email', 'Channel::Api', 'Channel::WebWidget'].include?(channel_type)
end
end
Inbox.prepend_mod_with('Inbox')
Inbox.include_mod_with('Audit::Inbox')
Inbox.include_mod_with('Concerns::Inbox')

View File

@@ -0,0 +1,21 @@
# == Schema Information
#
# Table name: inbox_assignment_policies
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# assignment_policy_id :bigint not null
# inbox_id :bigint not null
#
# Indexes
#
# index_inbox_assignment_policies_on_assignment_policy_id (assignment_policy_id)
# index_inbox_assignment_policies_on_inbox_id (inbox_id) UNIQUE
#
class InboxAssignmentPolicy < ApplicationRecord
belongs_to :inbox
belongs_to :assignment_policy
validates :inbox_id, uniqueness: true
end

View File

@@ -0,0 +1,39 @@
# == Schema Information
#
# Table name: inbox_members
#
# id :integer not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# inbox_id :integer not null
# user_id :integer not null
#
# Indexes
#
# index_inbox_members_on_inbox_id (inbox_id)
# index_inbox_members_on_inbox_id_and_user_id (inbox_id,user_id) UNIQUE
#
class InboxMember < ApplicationRecord
validates :inbox_id, presence: true
validates :user_id, presence: true
validates :user_id, uniqueness: { scope: :inbox_id }
belongs_to :user
belongs_to :inbox
after_create :add_agent_to_round_robin
after_destroy :remove_agent_from_round_robin
private
def add_agent_to_round_robin
::AutoAssignment::InboxRoundRobinService.new(inbox: inbox).add_agent_to_queue(user_id)
end
def remove_agent_from_round_robin
::AutoAssignment::InboxRoundRobinService.new(inbox: inbox).remove_agent_from_queue(user_id) if inbox.present?
end
end
InboxMember.include_mod_with('Audit::InboxMember')

View File

@@ -0,0 +1,65 @@
# == Schema Information
#
# Table name: installation_configs
#
# id :bigint not null, primary key
# locked :boolean default(TRUE), not null
# name :string not null
# serialized_value :jsonb not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_installation_configs_on_name (name) UNIQUE
# index_installation_configs_on_name_and_created_at (name,created_at) UNIQUE
#
class InstallationConfig < ApplicationRecord
# https://stackoverflow.com/questions/72970170/upgrading-to-rails-6-1-6-1-causes-psychdisallowedclass-tried-to-load-unspecif
# https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017
# FIX ME : fixes breakage of installation config. we need to migrate.
# Fix configuration in application.rb
serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess
before_validation :set_lock
validates :name, presence: true
validate :saml_sso_users_check, if: -> { name == 'ENABLE_SAML_SSO_LOGIN' }
# TODO: Get rid of default scope
# https://stackoverflow.com/a/1834250/939299
default_scope { order(created_at: :desc) }
scope :editable, -> { where(locked: false) }
after_commit :clear_cache
def value
# This is an extra hack again cause of the YAML serialization, in case of new object initialization in super admin
# It was throwing error as the default value of column '{}' was failing in deserialization.
return {}.with_indifferent_access if new_record? && @attributes['serialized_value']&.value_before_type_cast == '{}'
serialized_value[:value]
end
def value=(value_to_assigned)
self.serialized_value = {
value: value_to_assigned
}.with_indifferent_access
end
private
def set_lock
self.locked = true if locked.nil?
end
def clear_cache
GlobalConfig.clear_cache
end
def saml_sso_users_check
return unless value == false || value == 'false'
return unless User.exists?(provider: 'saml')
errors.add(:base, 'Cannot disable SAML SSO login while users are using SAML authentication')
end
end

View File

@@ -0,0 +1,5 @@
module Integrations
def self.table_name_prefix
'integrations_'
end
end

View File

@@ -0,0 +1,129 @@
class Integrations::App
include Linear::IntegrationHelper
attr_accessor :params
def initialize(params)
@params = params
end
def id
params[:id]
end
def name
I18n.t("integration_apps.#{params[:i18n_key]}.name")
end
def description
I18n.t("integration_apps.#{params[:i18n_key]}.description")
end
def short_description
I18n.t("integration_apps.#{params[:i18n_key]}.short_description")
end
def logo
params[:logo]
end
def fields
params[:fields]
end
# There is no way to get the account_id from the linear callback
# so we are using the generate_linear_token method to generate a token and encode it in the state parameter
def encode_state
generate_linear_token(Current.account.id)
end
def action
case params[:id]
when 'slack'
client_id = GlobalConfigService.load('SLACK_CLIENT_ID', nil)
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
when 'linear'
build_linear_action
else
params[:action]
end
end
def active?(account)
case params[:id]
when 'slack'
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
when 'linear'
account.feature_enabled?('linear_integration') && GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'shopify'
shopify_enabled?(account)
when 'leadsquared'
account.feature_enabled?('crm_integration')
when 'notion'
notion_enabled?(account)
else
true
end
end
def build_linear_action
app_id = GlobalConfigService.load('LINEAR_CLIENT_ID', nil)
[
"#{params[:action]}?response_type=code",
"client_id=#{app_id}",
"redirect_uri=#{self.class.linear_integration_url}",
"state=#{encode_state}",
'scope=read,write',
'prompt=consent',
'actor=app'
].join('&')
end
def enabled?(account)
case params[:id]
when 'webhook'
account.webhooks.exists?
when 'dashboard_apps'
account.dashboard_apps.exists?
else
account.hooks.exists?(app_id: id)
end
end
def hooks
Current.account.hooks.where(app_id: id)
end
def self.slack_integration_url
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/integrations/slack"
end
def self.linear_integration_url
"#{ENV.fetch('FRONTEND_URL', nil)}/linear/callback"
end
class << self
def apps
Hashie::Mash.new(APPS_CONFIG)
end
def all
apps.values.each_with_object([]) do |app, result|
result << new(app)
end
end
def find(params)
all.detect { |app| app.id == params[:id] }
end
end
private
def shopify_enabled?(account)
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
end
def notion_enabled?(account)
account.feature_enabled?('notion_integration') && GlobalConfigService.load('NOTION_CLIENT_ID', nil).present?
end
end

View File

@@ -0,0 +1,110 @@
# == Schema Information
#
# Table name: integrations_hooks
#
# id :bigint not null, primary key
# access_token :string
# hook_type :integer default("account")
# settings :jsonb
# status :integer default("enabled")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer
# app_id :string
# inbox_id :integer
# reference_id :string
#
class Integrations::Hook < ApplicationRecord
include Reauthorizable
attr_readonly :app_id, :account_id, :inbox_id, :hook_type
before_validation :ensure_hook_type
after_create :trigger_setup_if_crm
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
encrypts :access_token, deterministic: true if Chatwoot.encryption_configured?
validates :account_id, presence: true
validates :app_id, presence: true
validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' }
validate :validate_settings_json_schema
validate :ensure_feature_enabled
validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } }
# TODO: This seems to be only used for slack at the moment
# We can add a validator when storing the integration settings and toggle this in future
enum status: { disabled: 0, enabled: 1 }
belongs_to :account
belongs_to :inbox, optional: true
has_secure_token :access_token
enum hook_type: { account: 0, inbox: 1 }
scope :account_hooks, -> { where(hook_type: 'account') }
scope :inbox_hooks, -> { where(hook_type: 'inbox') }
def app
@app ||= Integrations::App.find(id: app_id)
end
def slack?
app_id == 'slack'
end
def dialogflow?
app_id == 'dialogflow'
end
def notion?
app_id == 'notion'
end
def disable
update(status: 'disabled')
end
def process_event(_event)
# OpenAI integration migrated to Captain::EditorService
# Other integrations (slack, dialogflow, etc.) handled via HookJob
{ error: 'No processor found' }
end
def feature_allowed?
return true if app.blank?
flag = app.params[:feature_flag]
return true unless flag
account.feature_enabled?(flag)
end
private
def ensure_feature_enabled
errors.add(:feature_flag, 'Feature not enabled') unless feature_allowed?
end
def ensure_hook_type
self.hook_type = app.params[:hook_type] if app.present?
end
def validate_settings_json_schema
return if app.blank? || app.params[:settings_json_schema].blank?
errors.add(:settings, ': Invalid settings data') unless JSONSchemer.schema(app.params[:settings_json_schema]).valid?(settings)
end
def trigger_setup_if_crm
# we need setup services to create data prerequisite to functioning of the integration
# in case of Leadsquared, we need to create a custom activity type for capturing conversations and transcripts
# https://apidocs.leadsquared.com/create-new-activity-type-api/
return unless crm_integration?
::Crm::SetupJob.perform_later(id)
end
def crm_integration?
%w[leadsquared].include?(app_id)
end
end

View File

@@ -0,0 +1,21 @@
class JsonbAttributesLengthValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.empty?
@attribute = attribute
@record = record
value.each do |key, attribute_value|
validate_keys(key, attribute_value)
end
end
def validate_keys(key, attribute_value)
case attribute_value.class.name
when 'String'
@record.errors.add @attribute, "#{key} length should be < 1500" if attribute_value.length > 1500
when 'Integer'
@record.errors.add @attribute, "#{key} value should be < 9999999999" if attribute_value > 9_999_999_999
end
end
end

View File

@@ -0,0 +1,5 @@
module Kbase
def self.table_name_prefix
'kbase_'
end
end

View File

@@ -0,0 +1,56 @@
# == Schema Information
#
# Table name: labels
#
# id :bigint not null, primary key
# color :string default("#1f93ff"), not null
# description :text
# show_on_sidebar :boolean
# title :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint
#
# Indexes
#
# index_labels_on_account_id (account_id)
# index_labels_on_title_and_account_id (title,account_id) UNIQUE
#
class Label < ApplicationRecord
include RegexHelper
include AccountCacheRevalidator
belongs_to :account
validates :title,
presence: { message: I18n.t('errors.validations.presence') },
format: { with: UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE },
uniqueness: { scope: :account_id }
after_update_commit :update_associated_models
default_scope { order(:title) }
before_validation do
self.title = title.downcase if attribute_present?('title')
end
def conversations
account.conversations.tagged_with(title)
end
def messages
account.messages.where(conversation_id: conversations.pluck(:id))
end
def reporting_events
account.reporting_events.where(conversation_id: conversations.pluck(:id))
end
private
def update_associated_models
return unless title_previously_changed?
Labels::UpdateJob.perform_later(title, title_previously_was, account_id)
end
end

View File

@@ -0,0 +1,78 @@
# == Schema Information
#
# Table name: macros
#
# id :bigint not null, primary key
# actions :jsonb not null
# name :string not null
# visibility :integer default("personal")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# created_by_id :bigint
# updated_by_id :bigint
#
# Indexes
#
# index_macros_on_account_id (account_id)
#
class Macro < ApplicationRecord
include Rails.application.routes.url_helpers
belongs_to :account
belongs_to :created_by,
class_name: :User, optional: true, inverse_of: :macros
belongs_to :updated_by,
class_name: :User, optional: true
has_many_attached :files
enum visibility: { personal: 0, global: 1 }
validate :json_actions_format
ACTIONS_ATTRS = %w[send_message add_label assign_team assign_agent mute_conversation change_status remove_label remove_assigned_team
resolve_conversation snooze_conversation change_priority send_email_transcript send_attachment
add_private_note send_webhook_event].freeze
def set_visibility(user, params)
self.visibility = params[:visibility]
self.visibility = :personal if user.agent?
end
def self.with_visibility(user, _params)
records = Current.account.macros.global
records = records.or(personal.where(created_by_id: user.id, account_id: Current.account.id))
records.order(:id)
end
def self.current_page(params)
params[:page] || 1
end
def file_base_data
files.map do |file|
{
id: file.id,
macro_id: id,
file_type: file.content_type,
account_id: account_id,
file_url: url_for(file),
blob_id: file.blob_id,
filename: file.filename.to_s
}
end
end
private
def json_actions_format
return if actions.blank?
attributes = actions.map { |obj, _| obj['action_name'] }
actions = attributes - ACTIONS_ATTRS
errors.add(:actions, "Macro execution actions #{actions.join(',')} not supported.") if actions.any?
end
end
Macro.include_mod_with('Audit::Macro')

View File

@@ -0,0 +1,58 @@
# == Schema Information
#
# Table name: mentions
#
# id :bigint not null, primary key
# mentioned_at :datetime not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# conversation_id :bigint not null
# user_id :bigint not null
#
# Indexes
#
# index_mentions_on_account_id (account_id)
# index_mentions_on_conversation_id (conversation_id)
# index_mentions_on_user_id (user_id)
# index_mentions_on_user_id_and_conversation_id (user_id,conversation_id) UNIQUE
#
class Mention < ApplicationRecord
include SortHandler
before_validation :ensure_account_id
validates :mentioned_at, presence: true
validates :account_id, presence: true
validates :conversation_id, presence: true
validates :user_id, presence: true
validates :user, uniqueness: { scope: :conversation }
belongs_to :account
belongs_to :conversation
belongs_to :user
after_commit :notify_mentioned_user
scope :latest, -> { order(mentioned_at: :desc) }
def self.last_user_message_at
# INNER query finds the last message created in the conversation group
# The outer query JOINS with the latest created message conversations
# Then select only latest incoming message from the conversations which doesn't have last message as outgoing
# Order by message created_at
Mention.joins(
"INNER JOIN (#{last_messaged_conversations.to_sql}) AS grouped_conversations
ON grouped_conversations.conversation_id = mentions.conversation_id"
).sort_on_last_user_message_at
end
private
def ensure_account_id
self.account_id = conversation&.account_id
end
def notify_mentioned_user
Rails.configuration.dispatcher.dispatch(CONVERSATION_MENTIONED, Time.zone.now, user: user, conversation: conversation)
end
end

View File

@@ -0,0 +1,428 @@
# == Schema Information
#
# Table name: messages
#
# id :integer not null, primary key
# additional_attributes :jsonb
# content :text
# content_attributes :json
# content_type :integer default("text"), not null
# external_source_ids :jsonb
# message_type :integer not null
# private :boolean default(FALSE), not null
# processed_message_content :text
# sender_type :string
# sentiment :jsonb
# status :integer default("sent")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# conversation_id :integer not null
# inbox_id :integer not null
# sender_id :bigint
# source_id :text
#
# Indexes
#
# idx_messages_account_content_created (account_id,content_type,created_at)
# index_messages_on_account_created_type (account_id,created_at,message_type)
# index_messages_on_account_id (account_id)
# index_messages_on_account_id_and_inbox_id (account_id,inbox_id)
# index_messages_on_additional_attributes_campaign_id (((additional_attributes -> 'campaign_id'::text))) USING gin
# index_messages_on_content (content) USING gin
# index_messages_on_conversation_account_type_created (conversation_id,account_id,message_type,created_at)
# index_messages_on_conversation_id (conversation_id)
# index_messages_on_created_at (created_at)
# index_messages_on_inbox_id (inbox_id)
# index_messages_on_sender_type_and_sender_id (sender_type,sender_id)
# index_messages_on_source_id (source_id)
#
class Message < ApplicationRecord
searchkick callbacks: false if ChatwootApp.advanced_search_allowed?
include MessageFilterHelpers
include Liquidable
NUMBER_OF_PERMITTED_ATTACHMENTS = 15
TEMPLATE_PARAMS_SCHEMA = {
'type': 'object',
'properties': {
'template_params': {
'type': 'object',
'properties': {
'name': { 'type': 'string' },
'category': { 'type': 'string' },
'language': { 'type': 'string' },
'namespace': { 'type': 'string' },
'processed_params': { 'type': 'object' }
},
'required': %w[name]
}
}
}.to_json.freeze
before_validation :ensure_content_type
before_validation :prevent_message_flooding
before_save :ensure_processed_message_content
before_save :ensure_in_reply_to
validates :account_id, presence: true
validates :inbox_id, presence: true
validates :conversation_id, presence: true
validates_with ContentAttributeValidator
validates_with JsonSchemaValidator,
schema: TEMPLATE_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.additional_attributes }
validates :content_type, presence: true
validates :content, length: { maximum: 150_000 }
validates :processed_message_content, length: { maximum: 150_000 }
# when you have a temperory id in your frontend and want it echoed back via action cable
attr_accessor :echo_id
enum message_type: { incoming: 0, outgoing: 1, activity: 2, template: 3 }
enum content_type: {
text: 0,
input_text: 1,
input_textarea: 2,
input_email: 3,
input_select: 4,
cards: 5,
form: 6,
article: 7,
incoming_email: 8,
input_csat: 9,
integrations: 10,
sticker: 11,
voice_call: 12
}
enum status: { sent: 0, delivered: 1, read: 2, failed: 3 }
# [:submitted_email, :items, :submitted_values] : Used for bot message types
# [:email] : Used by conversation_continuity incoming email messages
# [:in_reply_to] : Used to reply to a particular tweet in threads
# [:deleted] : Used to denote whether the message was deleted by the agent
# [:external_created_at] : Can specify if the message was created at a different timestamp externally
# [:external_error : Can specify if the message creation failed due to an error at external API
# [:data] : Used for structured content types such as voice_call
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
:external_created_at, :story_sender, :story_id, :external_error,
:translations, :in_reply_to_external_id, :is_unsupported, :data], coder: JSON
store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id
scope :created_since, ->(datetime) { where('created_at > ?', datetime) }
scope :chat, -> { where.not(message_type: :activity).where(private: false) }
scope :non_activity_messages, -> { where.not(message_type: :activity).reorder('created_at desc') }
scope :today, -> { where("date_trunc('day', created_at) = ?", Date.current) }
scope :voice_calls, -> { where(content_type: :voice_call) }
# TODO: Get rid of default scope
# https://stackoverflow.com/a/1834250/939299
# if you want to change order, use `reorder`
default_scope { order(created_at: :asc) }
belongs_to :account
belongs_to :inbox
belongs_to :conversation, touch: true
belongs_to :sender, polymorphic: true, optional: true
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
has_one :csat_survey_response, dependent: :destroy_async
has_many :notifications, as: :primary_actor, dependent: :destroy_async
after_create_commit :execute_after_create_commit_callbacks
after_update_commit :dispatch_update_event
after_commit :reindex_for_search, if: :should_index?, on: [:create, :update]
def channel_token
@token ||= inbox.channel.try(:page_access_token)
end
def push_event_data
data = attributes.symbolize_keys.merge(
created_at: created_at.to_i,
message_type: message_type_before_type_cast,
conversation_id: conversation&.display_id,
conversation: conversation.present? ? conversation_push_event_data : nil
)
data[:echo_id] = echo_id if echo_id.present?
data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
merge_sender_attributes(data)
end
def conversation_push_event_data
{
assignee_id: conversation.assignee_id,
unread_count: conversation.unread_incoming_messages.count,
last_activity_at: conversation.last_activity_at.to_i,
contact_inbox: { source_id: conversation.contact_inbox.source_id }
}
end
def merge_sender_attributes(data)
data[:sender] = sender.push_event_data if sender && !sender.is_a?(AgentBot)
data[:sender] = sender.push_event_data(inbox) if sender.is_a?(AgentBot)
data
end
def webhook_data
data = {
account: account.webhook_data,
additional_attributes: additional_attributes,
content_attributes: content_attributes,
content_type: content_type,
content: outgoing_content,
conversation: conversation.webhook_data,
created_at: created_at,
id: id,
inbox: inbox.webhook_data,
message_type: message_type,
private: private,
sender: sender.try(:webhook_data),
source_id: source_id
}
data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
data
end
# Method to get content with survey URL for outgoing channel delivery
def outgoing_content
MessageContentPresenter.new(self).outgoing_content
end
def email_notifiable_message?
return false if private?
return false if %w[outgoing template].exclude?(message_type)
return false if template? && %w[input_csat text].exclude?(content_type)
true
end
def auto_reply_email?
return false unless incoming_email? || inbox.email?
content_attributes.dig(:email, :auto_reply) == true
end
def valid_first_reply?
return false unless human_response? && !private?
return false if conversation.first_reply_created_at.present?
return false if conversation.messages.outgoing
.where.not(sender_type: ['AgentBot', 'Captain::Assistant'])
.where.not(private: true)
.where("(additional_attributes->'campaign_id') is null").count > 1
true
end
def save_story_info(story_info)
self.content_attributes = content_attributes.merge(
{
story_id: story_info['id'],
story_sender: inbox.channel.instagram_id,
story_url: story_info['url']
}
)
save!
end
def send_update_event
Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self, performed_by: Current.executed_by,
previous_changes: previous_changes)
end
def should_index?
return false unless ChatwootApp.advanced_search_allowed?
return false unless incoming? || outgoing?
# For Chatwoot Cloud:
# - Enable indexing only if the account is paid.
# - The `advanced_search_indexing` feature flag is used only in the cloud.
#
# For Self-hosted:
# - Adding an extra feature flag here would cause confusion.
# - If the user has configured Elasticsearch, enabling `advanced_search`
# should automatically work without any additional flags.
return false if ChatwootApp.chatwoot_cloud? && !account.feature_enabled?('advanced_search_indexing')
true
end
def search_data
Messages::SearchDataPresenter.new(self).search_data
end
# Returns message content suitable for LLM consumption
# Falls back to audio transcription or attachment placeholder when content is nil
def content_for_llm
return content if content.present?
audio_transcription = attachments
.where(file_type: :audio)
.filter_map { |att| att.meta&.dig('transcribed_text') }
.join(' ')
.presence
return "[Voice Message] #{audio_transcription}" if audio_transcription.present?
'[Attachment]' if attachments.any?
end
private
def prevent_message_flooding
# Added this to cover the validation specs in messages
# We can revisit and see if we can remove this later
return if conversation.blank?
# there are cases where automations can result in message loops, we need to prevent such cases.
if conversation.messages.where('created_at >= ?', 1.minute.ago).count >= Limits.conversation_message_per_minute_limit
Rails.logger.error "Too many message: Account Id - #{account_id} : Conversation id - #{conversation_id}"
errors.add(:base, 'Too many messages')
end
end
def ensure_processed_message_content
text_content_quoted = content_attributes.dig(:email, :text_content, :quoted)
html_content_quoted = content_attributes.dig(:email, :html_content, :quoted)
message_content = text_content_quoted || html_content_quoted || content
self.processed_message_content = message_content&.truncate(150_000)
end
# fetch the in_reply_to message and set the external id
def ensure_in_reply_to
in_reply_to = content_attributes[:in_reply_to]
in_reply_to_external_id = content_attributes[:in_reply_to_external_id]
Messages::InReplyToMessageBuilder.new(
message: self,
in_reply_to: in_reply_to,
in_reply_to_external_id: in_reply_to_external_id
).perform
end
def ensure_content_type
self.content_type ||= Message.content_types[:text]
end
def execute_after_create_commit_callbacks
# rails issue with order of active record callbacks being executed https://github.com/rails/rails/issues/20911
reopen_conversation
set_conversation_activity
dispatch_create_events
send_reply
execute_message_template_hooks
update_contact_activity
end
def update_contact_activity
sender.update(last_activity_at: DateTime.now) if sender.is_a?(Contact)
end
def update_waiting_since
waiting_present = conversation.waiting_since.present?
if waiting_present && !private
if human_response?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
elsif bot_response?
# Bot responses also clear waiting_since (simpler than checking on next customer message)
conversation.update(waiting_since: nil)
end
end
# Set waiting_since when customer sends a message (if currently blank)
conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank?
end
def human_response?
# if the sender is not a user, it's not a human response
# if automation rule id is present, it's not a human response
# if campaign id is present, it's not a human response
# external echo messages are responses sent from the native app (WhatsApp Business, Instagram)
outgoing? &&
content_attributes['automation_rule_id'].blank? &&
additional_attributes['campaign_id'].blank? &&
(sender.is_a?(User) || content_attributes['external_echo'].present?)
end
def bot_response?
# Check if this is a response from AgentBot or Captain::Assistant
outgoing? && sender_type.in?(['AgentBot', 'Captain::Assistant'])
end
def dispatch_create_events
Rails.configuration.dispatcher.dispatch(MESSAGE_CREATED, Time.zone.now, message: self, performed_by: Current.executed_by)
if valid_first_reply?
Rails.configuration.dispatcher.dispatch(FIRST_REPLY_CREATED, Time.zone.now, message: self, performed_by: Current.executed_by)
conversation.update(first_reply_created_at: created_at, waiting_since: nil)
else
update_waiting_since
end
end
def dispatch_update_event
# ref: https://github.com/rails/rails/issues/44500
# we want to skip the update event if the message is not updated
return if previous_changes.blank?
send_update_event
end
def send_reply
# FIXME: Giving it few seconds for the attachment to be uploaded to the service
# active storage attaches the file only after commit
attachments.blank? ? ::SendReplyJob.perform_later(id) : ::SendReplyJob.set(wait: 2.seconds).perform_later(id)
end
def reopen_conversation
return if conversation.muted?
return unless incoming?
conversation.open! if conversation.snoozed?
reopen_resolved_conversation if conversation.resolved?
end
def reopen_resolved_conversation
# mark resolved bot conversation as pending to be reopened by bot processor service
if conversation.inbox.active_bot?
conversation.pending!
elsif conversation.inbox.api?
Current.executed_by = sender if reopened_by_contact?
conversation.open!
else
conversation.open!
end
end
def reopened_by_contact?
incoming? && !private? && Current.user.class != sender.class && sender.instance_of?(Contact)
end
def execute_message_template_hooks
::MessageTemplates::HookExecutionService.new(message: self).perform
end
def validate_attachments_limit(_attachment)
errors.add(:attachments, message: 'exceeded maximum allowed') if attachments.size >= NUMBER_OF_PERMITTED_ATTACHMENTS
end
def set_conversation_activity
# rubocop:disable Rails/SkipsModelValidations
conversation.update_columns(last_activity_at: created_at)
# rubocop:enable Rails/SkipsModelValidations
end
def reindex_for_search
reindex(mode: :async)
end
end
Message.prepend_mod_with('Message')

View File

@@ -0,0 +1,36 @@
# == Schema Information
#
# Table name: notes
#
# id :bigint not null, primary key
# content :text not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# contact_id :bigint not null
# user_id :bigint
#
# Indexes
#
# index_notes_on_account_id (account_id)
# index_notes_on_contact_id (contact_id)
# index_notes_on_user_id (user_id)
#
class Note < ApplicationRecord
before_validation :ensure_account_id
validates :content, presence: true
validates :account_id, presence: true
validates :contact_id, presence: true
belongs_to :account
belongs_to :contact
belongs_to :user, optional: true
scope :latest, -> { order(created_at: :desc) }
private
def ensure_account_id
self.account_id = contact&.account_id
end
end

View File

@@ -0,0 +1,208 @@
# == Schema Information
#
# Table name: notifications
#
# id :bigint not null, primary key
# last_activity_at :datetime
# meta :jsonb
# notification_type :integer not null
# primary_actor_type :string not null
# read_at :datetime
# secondary_actor_type :string
# snoozed_until :datetime
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# primary_actor_id :bigint not null
# secondary_actor_id :bigint
# user_id :bigint not null
#
# Indexes
#
# idx_notifications_performance (user_id,account_id,snoozed_until,read_at)
# index_notifications_on_account_id (account_id)
# index_notifications_on_last_activity_at (last_activity_at)
# index_notifications_on_user_id (user_id)
# uniq_primary_actor_per_account_notifications (primary_actor_type,primary_actor_id)
# uniq_secondary_actor_per_account_notifications (secondary_actor_type,secondary_actor_id)
#
class Notification < ApplicationRecord
include MessageFormatHelper
belongs_to :account
belongs_to :user
belongs_to :primary_actor, polymorphic: true
belongs_to :secondary_actor, polymorphic: true, optional: true
NOTIFICATION_TYPES = {
conversation_creation: 1,
conversation_assignment: 2,
assigned_conversation_new_message: 3,
conversation_mention: 4,
participating_conversation_new_message: 5,
sla_missed_first_response: 6,
sla_missed_next_response: 7,
sla_missed_resolution: 8
}.freeze
enum notification_type: NOTIFICATION_TYPES
before_create :set_last_activity_at
after_create_commit :process_notification_delivery, :dispatch_create_event
after_destroy_commit :dispatch_destroy_event
after_update_commit :dispatch_update_event
PRIMARY_ACTORS = ['Conversation'].freeze
def push_event_data
# Secondary actor could be nil for cases like system assigning conversation
payload = {
id: id,
notification_type: notification_type,
primary_actor_type: primary_actor_type,
primary_actor_id: primary_actor_id,
read_at: read_at,
secondary_actor: secondary_actor&.push_event_data,
user: user&.push_event_data,
created_at: created_at.to_i,
last_activity_at: last_activity_at.to_i,
snoozed_until: snoozed_until,
meta: meta,
account_id: account_id
}
payload.merge!(primary_actor_data) if primary_actor.present?
payload
end
def fcm_push_data
{
id: id,
notification_type: notification_type,
primary_actor_id: primary_actor_id,
primary_actor_type: primary_actor_type,
primary_actor: primary_actor.push_event_data.with_indifferent_access.slice('conversation_id', 'id')
}
end
# rubocop:disable Metrics/MethodLength
def push_message_title
notification_title_map = {
'conversation_creation' => 'notifications.notification_title.conversation_creation',
'conversation_assignment' => 'notifications.notification_title.conversation_assignment',
'assigned_conversation_new_message' => 'notifications.notification_title.assigned_conversation_new_message',
'participating_conversation_new_message' => 'notifications.notification_title.assigned_conversation_new_message',
'conversation_mention' => 'notifications.notification_title.conversation_mention',
'sla_missed_first_response' => 'notifications.notification_title.sla_missed_first_response',
'sla_missed_next_response' => 'notifications.notification_title.sla_missed_next_response',
'sla_missed_resolution' => 'notifications.notification_title.sla_missed_resolution'
}
i18n_key = notification_title_map[notification_type]
return '' unless i18n_key
if notification_type == 'conversation_creation'
I18n.t(i18n_key, display_id: conversation.display_id, inbox_name: primary_actor.inbox.name)
elsif %w[conversation_assignment assigned_conversation_new_message participating_conversation_new_message
conversation_mention].include?(notification_type)
I18n.t(i18n_key, display_id: conversation.display_id)
else
I18n.t(i18n_key, display_id: primary_actor.display_id)
end
end
# rubocop:enable Metrics/MethodLength
def push_message_body
case notification_type
when 'conversation_creation', 'sla_missed_first_response'
message_body(conversation.messages.first)
when 'assigned_conversation_new_message', 'participating_conversation_new_message', 'conversation_mention'
message_body(secondary_actor)
when 'conversation_assignment', 'sla_missed_next_response', 'sla_missed_resolution'
message_body((conversation.messages.incoming.last || conversation.messages.outgoing.last))
else
''
end
end
def conversation
primary_actor
end
private
def message_body(actor)
sender_name = sender_name(actor)
content = message_content(actor)
"#{sender_name}: #{content}"
end
def sender_name(actor)
actor.try(:sender)&.name || ''
end
def message_content(actor)
content = actor.try(:content)
attachments = actor.try(:attachments)
if content.present?
transform_user_mention_content(content.truncate_words(10))
else
attachments.present? ? I18n.t('notifications.attachment') : I18n.t('notifications.no_content')
end
end
def process_notification_delivery
Notification::PushNotificationJob.perform_later(self) if user_subscribed_to_notification?('push')
# Should we do something about the case where user subscribed to both push and email ?
# In future, we could probably add condition here to enqueue the job for 30 seconds later
# when push enabled and then check in email job whether notification has been read already.
Notification::EmailNotificationJob.perform_later(self) if user_subscribed_to_notification?('email')
Notification::RemoveDuplicateNotificationJob.perform_later(self)
end
def user_subscribed_to_notification?(delivery_type)
notification_setting = user.notification_settings.find_by(account_id: account.id)
return false if notification_setting.blank?
# Check if the user has subscribed to the specified type of notification
notification_setting.public_send("#{delivery_type}_#{notification_type}?")
end
def dispatch_create_event
Rails.configuration.dispatcher.dispatch(NOTIFICATION_CREATED, Time.zone.now, notification: self)
end
def dispatch_update_event
Rails.configuration.dispatcher.dispatch(NOTIFICATION_UPDATED, Time.zone.now, notification: self)
end
def dispatch_destroy_event
# Pass serialized data instead of ActiveRecord object to avoid DeserializationError
# when the async EventDispatcherJob runs after the notification has been deleted
Rails.configuration.dispatcher.dispatch(
NOTIFICATION_DELETED,
Time.zone.now,
notification_data: {
id: id,
user_id: user_id,
account_id: account_id
}
)
end
def set_last_activity_at
self.last_activity_at = created_at
end
def primary_actor_data
{
primary_actor: primary_actor&.push_event_data,
# TODO: Rename push_message_title to push_message_body
push_message_title: push_message_body,
push_message_body: push_message_body
}
end
end

View File

@@ -0,0 +1,35 @@
# == Schema Information
#
# Table name: notification_settings
#
# id :bigint not null, primary key
# email_flags :integer default(0), not null
# push_flags :integer default(0), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer
# user_id :integer
#
# Indexes
#
# by_account_user (account_id,user_id) UNIQUE
#
class NotificationSetting < ApplicationRecord
# used for single column multi flags
include FlagShihTzu
belongs_to :account
belongs_to :user
DEFAULT_QUERY_SETTING = {
flag_query_mode: :bit_operator,
check_for_column: false
}.freeze
EMAIL_NOTIFICATION_FLAGS = ::Notification::NOTIFICATION_TYPES.transform_keys { |key| "email_#{key}".to_sym }.invert.freeze
PUSH_NOTIFICATION_FLAGS = ::Notification::NOTIFICATION_TYPES.transform_keys { |key| "push_#{key}".to_sym }.invert.freeze
has_flags EMAIL_NOTIFICATION_FLAGS.merge(column: 'email_flags').merge(DEFAULT_QUERY_SETTING)
has_flags PUSH_NOTIFICATION_FLAGS.merge(column: 'push_flags').merge(DEFAULT_QUERY_SETTING)
end

View File

@@ -0,0 +1,29 @@
# == Schema Information
#
# Table name: notification_subscriptions
#
# id :bigint not null, primary key
# identifier :text
# subscription_attributes :jsonb not null
# subscription_type :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_notification_subscriptions_on_identifier (identifier) UNIQUE
# index_notification_subscriptions_on_user_id (user_id)
#
class NotificationSubscription < ApplicationRecord
belongs_to :user
validates :identifier, presence: true
SUBSCRIPTION_TYPES = {
browser_push: 1,
fcm: 2
}.freeze
enum subscription_type: SUBSCRIPTION_TYPES
end

View File

@@ -0,0 +1,16 @@
# == Schema Information
#
# Table name: platform_apps
#
# id :bigint not null, primary key
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
#
class PlatformApp < ApplicationRecord
include AccessTokenable
validates :name, presence: true
has_many :platform_app_permissibles, dependent: :destroy_async
end

View File

@@ -0,0 +1,24 @@
# == Schema Information
#
# Table name: platform_app_permissibles
#
# id :bigint not null, primary key
# permissible_type :string not null
# created_at :datetime not null
# updated_at :datetime not null
# permissible_id :bigint not null
# platform_app_id :bigint not null
#
# Indexes
#
# index_platform_app_permissibles_on_permissibles (permissible_type,permissible_id)
# index_platform_app_permissibles_on_platform_app_id (platform_app_id)
# unique_permissibles_index (platform_app_id,permissible_id,permissible_type) UNIQUE
#
class PlatformAppPermissible < ApplicationRecord
validates :platform_app, presence: true
validates :platform_app_id, uniqueness: { scope: [:permissible_id, :permissible_type] }
belongs_to :platform_app
belongs_to :permissible, polymorphic: true
end

View File

@@ -0,0 +1,74 @@
# == Schema Information
#
# Table name: portals
#
# id :bigint not null, primary key
# archived :boolean default(FALSE)
# color :string
# config :jsonb
# custom_domain :string
# header_text :text
# homepage_link :string
# name :string not null
# page_title :string
# slug :string not null
# ssl_settings :jsonb not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# channel_web_widget_id :bigint
#
# Indexes
#
# index_portals_on_channel_web_widget_id (channel_web_widget_id)
# index_portals_on_custom_domain (custom_domain) UNIQUE
# index_portals_on_slug (slug) UNIQUE
#
class Portal < ApplicationRecord
include Rails.application.routes.url_helpers
belongs_to :account
has_many :categories, dependent: :destroy_async
has_many :folders, through: :categories
has_many :articles, dependent: :destroy_async
has_one_attached :logo
has_many :inboxes, dependent: :nullify
belongs_to :channel_web_widget, class_name: 'Channel::WebWidget', optional: true
before_validation -> { normalize_empty_string_to_nil(%i[custom_domain homepage_link]) }
validates :account_id, presence: true
validates :name, presence: true
validates :slug, presence: true, uniqueness: true
validates :custom_domain, uniqueness: true, allow_nil: true
validate :config_json_format
scope :active, -> { where(archived: false) }
CONFIG_JSON_KEYS = %w[allowed_locales default_locale website_token].freeze
def file_base_data
{
id: logo.id,
portal_id: id,
file_type: logo.content_type,
account_id: account_id,
file_url: url_for(logo),
blob_id: logo.blob.signed_id,
filename: logo.filename.to_s
}
end
def default_locale
config['default_locale'] || 'en'
end
private
def config_json_format
config['default_locale'] = default_locale
denied_keys = config.keys - CONFIG_JSON_KEYS
errors.add(:cofig, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any?
end
end
Portal.include_mod_with('Concerns::Portal')

View File

@@ -0,0 +1,19 @@
# == Schema Information
#
# Table name: related_categories
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# category_id :bigint
# related_category_id :bigint
#
# Indexes
#
# index_related_categories_on_category_id_and_related_category_id (category_id,related_category_id) UNIQUE
# index_related_categories_on_related_category_id_and_category_id (related_category_id,category_id) UNIQUE
#
class RelatedCategory < ApplicationRecord
belongs_to :related_category, class_name: 'Category'
belongs_to :category, class_name: 'Category'
end

View File

@@ -0,0 +1,55 @@
# == Schema Information
#
# Table name: reporting_events
#
# id :bigint not null, primary key
# event_end_time :datetime
# event_start_time :datetime
# name :string
# value :float
# value_in_business_hours :float
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer
# conversation_id :integer
# inbox_id :integer
# user_id :integer
#
# Indexes
#
# index_reporting_events_on_account_id (account_id)
# index_reporting_events_on_conversation_id (conversation_id)
# index_reporting_events_on_created_at (created_at)
# index_reporting_events_on_inbox_id (inbox_id)
# index_reporting_events_on_name (name)
# index_reporting_events_on_user_id (user_id)
# reporting_events__account_id__name__created_at (account_id,name,created_at)
#
class ReportingEvent < ApplicationRecord
validates :account_id, presence: true
validates :name, presence: true
validates :value, presence: true
belongs_to :account
belongs_to :user, optional: true
belongs_to :inbox, optional: true
belongs_to :conversation, optional: true
# Scopes for filtering
scope :filter_by_date_range, lambda { |range|
where(created_at: range) if range.present?
}
scope :filter_by_inbox_id, lambda { |inbox_id|
where(inbox_id: inbox_id) if inbox_id.present?
}
scope :filter_by_user_id, lambda { |user_id|
where(user_id: user_id) if user_id.present?
}
scope :filter_by_name, lambda { |name|
where(name: name) if name.present?
}
end

View File

@@ -0,0 +1,48 @@
# == Schema Information
#
# Table name: users
#
# id :integer not null, primary key
# availability :integer default("online")
# confirmation_sent_at :datetime
# confirmation_token :string
# confirmed_at :datetime
# consumed_timestep :integer
# current_sign_in_at :datetime
# current_sign_in_ip :string
# custom_attributes :jsonb
# display_name :string
# email :string
# encrypted_password :string default(""), not null
# last_sign_in_at :datetime
# last_sign_in_ip :string
# message_signature :text
# name :string not null
# otp_backup_codes :text
# otp_required_for_login :boolean default(FALSE)
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
# remember_created_at :datetime
# reset_password_sent_at :datetime
# reset_password_token :string
# sign_in_count :integer default(0), not null
# tokens :json
# type :string
# ui_settings :jsonb
# uid :string default(""), not null
# unconfirmed_email :string
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_users_on_email (email)
# index_users_on_otp_required_for_login (otp_required_for_login)
# index_users_on_otp_secret (otp_secret) UNIQUE
# index_users_on_pubsub_token (pubsub_token) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_uid_and_provider (uid,provider) UNIQUE
#
class SuperAdmin < User
end

View File

@@ -0,0 +1,70 @@
# == Schema Information
#
# Table name: teams
#
# id :bigint not null, primary key
# allow_auto_assign :boolean default(TRUE)
# description :text
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_teams_on_account_id (account_id)
# index_teams_on_name_and_account_id (name,account_id) UNIQUE
#
class Team < ApplicationRecord
include AccountCacheRevalidator
belongs_to :account
has_many :team_members, dependent: :destroy_async
has_many :members, through: :team_members, source: :user
has_many :conversations, dependent: :nullify
validates :name,
presence: { message: I18n.t('errors.validations.presence') },
uniqueness: { scope: :account_id }
before_validation do
self.name = name.downcase if attribute_present?('name')
end
# Adds multiple members to the team
# @param user_ids [Array<Integer>] Array of user IDs to add as members
# @return [Array<User>] Array of newly added members
def add_members(user_ids)
team_members_to_create = user_ids.map { |user_id| { user_id: user_id } }
created_members = team_members.create(team_members_to_create)
added_users = created_members.filter_map(&:user)
update_account_cache
added_users
end
# Removes multiple members from the team
# @param user_ids [Array<Integer>] Array of user IDs to remove
# @return [void]
def remove_members(user_ids)
team_members.where(user_id: user_ids).destroy_all
update_account_cache
end
def messages
account.messages.where(conversation_id: conversations.pluck(:id))
end
def reporting_events
account.reporting_events.where(conversation_id: conversations.pluck(:id))
end
def push_event_data
{
id: id,
name: name
}
end
end
Team.include_mod_with('Audit::Team')

View File

@@ -0,0 +1,23 @@
# == Schema Information
#
# Table name: team_members
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# team_id :bigint not null
# user_id :bigint not null
#
# Indexes
#
# index_team_members_on_team_id (team_id)
# index_team_members_on_team_id_and_user_id (team_id,user_id) UNIQUE
# index_team_members_on_user_id (user_id)
#
class TeamMember < ApplicationRecord
belongs_to :user
belongs_to :team
validates :user_id, uniqueness: { scope: :team_id }
end
TeamMember.include_mod_with('Audit::TeamMember')

View File

@@ -0,0 +1,203 @@
# == Schema Information
#
# Table name: users
#
# id :integer not null, primary key
# availability :integer default("online")
# confirmation_sent_at :datetime
# confirmation_token :string
# confirmed_at :datetime
# consumed_timestep :integer
# current_sign_in_at :datetime
# current_sign_in_ip :string
# custom_attributes :jsonb
# display_name :string
# email :string
# encrypted_password :string default(""), not null
# last_sign_in_at :datetime
# last_sign_in_ip :string
# message_signature :text
# name :string not null
# otp_backup_codes :text
# otp_required_for_login :boolean default(FALSE)
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
# remember_created_at :datetime
# reset_password_sent_at :datetime
# reset_password_token :string
# sign_in_count :integer default(0), not null
# tokens :json
# type :string
# ui_settings :jsonb
# uid :string default(""), not null
# unconfirmed_email :string
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_users_on_email (email)
# index_users_on_otp_required_for_login (otp_required_for_login)
# index_users_on_otp_secret (otp_secret) UNIQUE
# index_users_on_pubsub_token (pubsub_token) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_uid_and_provider (uid,provider) UNIQUE
#
class User < ApplicationRecord
include AccessTokenable
include Avatarable
# Include default devise modules.
include DeviseTokenAuth::Concerns::User
include Pubsubable
include Rails.application.routes.url_helpers
include Reportable
include SsoAuthenticatable
include UserAttributeHelpers
devise :database_authenticatable,
:registerable,
:recoverable,
:rememberable,
:trackable,
:validatable,
:confirmable,
:password_has_required_content,
:two_factor_authenticatable,
:omniauthable, omniauth_providers: [:google_oauth2, :saml]
# TODO: remove in a future version once online status is moved to account users
# remove the column availability from users
enum availability: { online: 0, offline: 1, busy: 2 }
# The validation below has been commented out as it does not
# work because :validatable in devise overrides this.
# validates_uniqueness_of :email, scope: :account_id
validates :email, presence: true
serialize :otp_backup_codes, type: Array
# Encrypt sensitive MFA fields
encrypts :otp_secret, deterministic: true
encrypts :otp_backup_codes
has_many :account_users, dependent: :destroy_async
has_many :accounts, through: :account_users
accepts_nested_attributes_for :account_users
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee
alias_attribute :conversations, :assigned_conversations
has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent
has_many :reviewed_csat_survey_responses, foreign_key: 'review_notes_updated_by_id', class_name: 'CsatSurveyResponse',
dependent: :nullify, inverse_of: :review_notes_updated_by
has_many :conversation_participants, dependent: :destroy_async
has_many :participating_conversations, through: :conversation_participants, source: :conversation
has_many :inbox_members, dependent: :destroy_async
has_many :inboxes, through: :inbox_members, source: :inbox
has_many :messages, as: :sender, dependent: :nullify
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify
has_many :custom_filters, dependent: :destroy_async
has_many :dashboard_apps, dependent: :nullify
has_many :mentions, dependent: :destroy_async
has_many :notes, dependent: :nullify
has_many :notification_settings, dependent: :destroy_async
has_many :notification_subscriptions, dependent: :destroy_async
has_many :notifications, dependent: :destroy_async
has_many :team_members, dependent: :destroy_async
has_many :teams, through: :team_members
has_many :articles, foreign_key: 'author_id', dependent: :nullify, inverse_of: :author
# rubocop:disable Rails/HasManyOrHasOneDependent
# we are handling this in `remove_macros` callback
has_many :macros, foreign_key: 'created_by_id', inverse_of: :created_by
# rubocop:enable Rails/HasManyOrHasOneDependent
before_validation :set_password_and_uid, on: :create
after_destroy :remove_macros
scope :order_by_full_name, -> { order('lower(name) ASC') }
before_validation do
self.email = email.try(:downcase)
end
def send_devise_notification(notification, *)
devise_mailer.with(account: Current.account).send(notification, self, *).deliver_later
end
def set_password_and_uid
self.uid = email
end
def assigned_inboxes
administrator? ? Current.account.inboxes : inboxes.where(account_id: Current.account.id)
end
def serializable_hash(options = nil)
super(options).merge(confirmed: confirmed?)
end
def push_event_data
{
id: id,
name: name,
available_name: available_name,
avatar_url: avatar_url,
type: 'user',
availability_status: availability_status,
thumbnail: avatar_url
}
end
def webhook_data
{
id: id,
name: name,
email: email,
type: 'user'
}
end
# https://github.com/lynndylanhurley/devise_token_auth/blob/6d7780ee0b9750687e7e2871b9a1c6368f2085a9/app/models/devise_token_auth/concerns/user.rb#L45
# Since this method is overriden in devise_token_auth it breaks the email reconfirmation flow.
def will_save_change_to_email?
mutations_from_database.changed?('email')
end
def self.from_email(email)
find_by(email: email&.downcase)
end
# 2FA/MFA Methods
# Delegated to Mfa::ManagementService for better separation of concerns
def mfa_service
@mfa_service ||= Mfa::ManagementService.new(user: self)
end
delegate :two_factor_provisioning_uri, to: :mfa_service
delegate :backup_codes_generated?, to: :mfa_service
delegate :enable_two_factor!, to: :mfa_service
delegate :disable_two_factor!, to: :mfa_service
delegate :generate_backup_codes!, to: :mfa_service
delegate :validate_backup_code!, to: :mfa_service
def mfa_enabled?
otp_required_for_login?
end
def mfa_feature_available?
Chatwoot.mfa_enabled?
end
private
def remove_macros
macros.personal.destroy_all
end
end
User.include_mod_with('Audit::User')
User.include_mod_with('Concerns::User')

View File

@@ -0,0 +1,43 @@
# == Schema Information
#
# Table name: webhooks
#
# id :bigint not null, primary key
# name :string
# subscriptions :jsonb
# url :text
# webhook_type :integer default("account_type")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer
# inbox_id :integer
#
# Indexes
#
# index_webhooks_on_account_id_and_url (account_id,url) UNIQUE
#
class Webhook < ApplicationRecord
belongs_to :account
belongs_to :inbox, optional: true
validates :account_id, presence: true
validates :url, uniqueness: { scope: [:account_id] }, format: URI::DEFAULT_PARSER.make_regexp(%w[http https])
validate :validate_webhook_subscriptions
enum webhook_type: { account_type: 0, inbox_type: 1 }
ALLOWED_WEBHOOK_EVENTS = %w[conversation_status_changed conversation_updated conversation_created contact_created contact_updated
message_created message_updated webwidget_triggered inbox_created inbox_updated
conversation_typing_on conversation_typing_off].freeze
private
def validate_webhook_subscriptions
invalid_subscriptions = !subscriptions.instance_of?(Array) ||
subscriptions.blank? ||
(subscriptions.uniq - ALLOWED_WEBHOOK_EVENTS).length.positive?
errors.add(:subscriptions, I18n.t('errors.webhook.invalid')) if invalid_subscriptions
end
end
Webhook.include_mod_with('Audit::Webhook')

View File

@@ -0,0 +1,93 @@
# == Schema Information
#
# Table name: working_hours
#
# id :bigint not null, primary key
# close_hour :integer
# close_minutes :integer
# closed_all_day :boolean default(FALSE)
# day_of_week :integer not null
# open_all_day :boolean default(FALSE)
# open_hour :integer
# open_minutes :integer
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint
# inbox_id :bigint
#
# Indexes
#
# index_working_hours_on_account_id (account_id)
# index_working_hours_on_inbox_id (inbox_id)
#
class WorkingHour < ApplicationRecord
belongs_to :inbox
before_validation :ensure_open_all_day_hours
before_save :assign_account
validates :open_hour, presence: true, unless: :closed_all_day?
validates :open_minutes, presence: true, unless: :closed_all_day?
validates :close_hour, presence: true, unless: :closed_all_day?
validates :close_minutes, presence: true, unless: :closed_all_day?
validates :open_hour, inclusion: 0..23, unless: :closed_all_day?
validates :close_hour, inclusion: 0..23, unless: :closed_all_day?
validates :open_minutes, inclusion: 0..59, unless: :closed_all_day?
validates :close_minutes, inclusion: 0..59, unless: :closed_all_day?
validate :close_after_open, unless: :closed_all_day?
validate :open_all_day_and_closed_all_day
def self.today
# While getting the day of the week, consider the timezone as well. `first` would
# return the first working hour from the list of working hours available per week.
inbox = first.inbox
find_by(day_of_week: Time.zone.now.in_time_zone(inbox.timezone).to_date.wday)
end
def open_at?(time)
return false if closed_all_day?
open_time = Time.zone.now.in_time_zone(inbox.timezone).change({ hour: open_hour, min: open_minutes })
close_time = Time.zone.now.in_time_zone(inbox.timezone).change({ hour: close_hour, min: close_minutes })
time.between?(open_time, close_time)
end
def open_now?
inbox_time = Time.zone.now.in_time_zone(inbox.timezone)
open_at?(inbox_time)
end
def closed_now?
!open_now?
end
private
def assign_account
self.account_id = inbox.account_id
end
def close_after_open
return unless open_hour.hours + open_minutes.minutes >= close_hour.hours + close_minutes.minutes
errors.add(:close_hour, 'Closing time cannot be before opening time')
end
def ensure_open_all_day_hours
return unless open_all_day?
self.open_hour = 0
self.open_minutes = 0
self.close_hour = 23
self.close_minutes = 59
end
def open_all_day_and_closed_all_day
return unless open_all_day? && closed_all_day?
errors.add(:base, 'open_all_day and closed_all_day cannot be true at the same time')
end
end