Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
21
research/chatwoot/app/models/access_token.rb
Normal file
21
research/chatwoot/app/models/access_token.rb
Normal 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
|
||||
224
research/chatwoot/app/models/account.rb
Normal file
224
research/chatwoot/app/models/account.rb
Normal 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')
|
||||
86
research/chatwoot/app/models/account_user.rb
Normal file
86
research/chatwoot/app/models/account_user.rb
Normal 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')
|
||||
65
research/chatwoot/app/models/agent_bot.rb
Normal file
65
research/chatwoot/app/models/agent_bot.rb
Normal 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
|
||||
29
research/chatwoot/app/models/agent_bot_inbox.rb
Normal file
29
research/chatwoot/app/models/agent_bot_inbox.rb
Normal 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
|
||||
53
research/chatwoot/app/models/application_record.rb
Normal file
53
research/chatwoot/app/models/application_record.rb
Normal 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')
|
||||
195
research/chatwoot/app/models/article.rb
Normal file
195
research/chatwoot/app/models/article.rb
Normal 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')
|
||||
37
research/chatwoot/app/models/assignment_policy.rb
Normal file
37
research/chatwoot/app/models/assignment_policy.rb
Normal 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')
|
||||
188
research/chatwoot/app/models/attachment.rb
Normal file
188
research/chatwoot/app/models/attachment.rb
Normal 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')
|
||||
109
research/chatwoot/app/models/automation_rule.rb
Normal file
109
research/chatwoot/app/models/automation_rule.rb
Normal 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')
|
||||
131
research/chatwoot/app/models/campaign.rb
Normal file
131
research/chatwoot/app/models/campaign.rb
Normal 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
|
||||
30
research/chatwoot/app/models/canned_response.rb
Normal file
30
research/chatwoot/app/models/canned_response.rb
Normal 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
|
||||
91
research/chatwoot/app/models/category.rb
Normal file
91
research/chatwoot/app/models/category.rb
Normal 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
|
||||
44
research/chatwoot/app/models/channel/api.rb
Normal file
44
research/chatwoot/app/models/channel/api.rb
Normal 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
|
||||
80
research/chatwoot/app/models/channel/email.rb
Normal file
80
research/chatwoot/app/models/channel/email.rb
Normal 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
|
||||
68
research/chatwoot/app/models/channel/facebook_page.rb
Normal file
68
research/chatwoot/app/models/channel/facebook_page.rb
Normal 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
|
||||
75
research/chatwoot/app/models/channel/instagram.rb
Normal file
75
research/chatwoot/app/models/channel/instagram.rb
Normal 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
|
||||
47
research/chatwoot/app/models/channel/line.rb
Normal file
47
research/chatwoot/app/models/channel/line.rb
Normal 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
|
||||
99
research/chatwoot/app/models/channel/sms.rb
Normal file
99
research/chatwoot/app/models/channel/sms.rb
Normal 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
|
||||
171
research/chatwoot/app/models/channel/telegram.rb
Normal file
171
research/chatwoot/app/models/channel/telegram.rb
Normal 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
|
||||
45
research/chatwoot/app/models/channel/tiktok.rb
Normal file
45
research/chatwoot/app/models/channel/tiktok.rb
Normal 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
|
||||
78
research/chatwoot/app/models/channel/twilio_sms.rb
Normal file
78
research/chatwoot/app/models/channel/twilio_sms.rb
Normal 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
|
||||
68
research/chatwoot/app/models/channel/twitter_profile.rb
Normal file
68
research/chatwoot/app/models/channel/twitter_profile.rb
Normal 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
|
||||
108
research/chatwoot/app/models/channel/web_widget.rb
Normal file
108
research/chatwoot/app/models/channel/web_widget.rb
Normal 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
|
||||
96
research/chatwoot/app/models/channel/whatsapp.rb
Normal file
96
research/chatwoot/app/models/channel/whatsapp.rb
Normal 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
|
||||
0
research/chatwoot/app/models/concerns/.keep
Normal file
0
research/chatwoot/app/models/concerns/.keep
Normal file
11
research/chatwoot/app/models/concerns/access_tokenable.rb
Normal file
11
research/chatwoot/app/models/concerns/access_tokenable.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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')
|
||||
55
research/chatwoot/app/models/concerns/assignment_handler.rb
Normal file
55
research/chatwoot/app/models/concerns/assignment_handler.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
36
research/chatwoot/app/models/concerns/avatarable.rb
Normal file
36
research/chatwoot/app/models/concerns/avatarable.rb
Normal 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
|
||||
46
research/chatwoot/app/models/concerns/cache_keys.rb
Normal file
46
research/chatwoot/app/models/concerns/cache_keys.rb
Normal 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
|
||||
62
research/chatwoot/app/models/concerns/captain_featurable.rb
Normal file
62
research/chatwoot/app/models/concerns/captain_featurable.rb
Normal 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
|
||||
13
research/chatwoot/app/models/concerns/channelable.rb
Normal file
13
research/chatwoot/app/models/concerns/channelable.rb
Normal 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')
|
||||
@@ -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
|
||||
@@ -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
|
||||
71
research/chatwoot/app/models/concerns/featurable.rb
Normal file
71
research/chatwoot/app/models/concerns/featurable.rb
Normal 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
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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
|
||||
19
research/chatwoot/app/models/concerns/labelable.rb
Normal file
19
research/chatwoot/app/models/concerns/labelable.rb
Normal 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
|
||||
96
research/chatwoot/app/models/concerns/liquidable.rb
Normal file
96
research/chatwoot/app/models/concerns/liquidable.rb
Normal 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
|
||||
7
research/chatwoot/app/models/concerns/llm_formattable.rb
Normal file
7
research/chatwoot/app/models/concerns/llm_formattable.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
module LlmFormattable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def to_llm_text(config = {})
|
||||
LlmFormatter::LlmTextFormatterService.new(self).format(config)
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
53
research/chatwoot/app/models/concerns/out_of_offisable.rb
Normal file
53
research/chatwoot/app/models/concerns/out_of_offisable.rb
Normal 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
|
||||
@@ -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
|
||||
26
research/chatwoot/app/models/concerns/pubsubable.rb
Normal file
26
research/chatwoot/app/models/concerns/pubsubable.rb
Normal 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
|
||||
15
research/chatwoot/app/models/concerns/push_data_helper.rb
Normal file
15
research/chatwoot/app/models/concerns/push_data_helper.rb
Normal 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
|
||||
97
research/chatwoot/app/models/concerns/reauthorizable.rb
Normal file
97
research/chatwoot/app/models/concerns/reauthorizable.rb
Normal 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
|
||||
9
research/chatwoot/app/models/concerns/reportable.rb
Normal file
9
research/chatwoot/app/models/concerns/reportable.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Reportable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :reporting_events, dependent: :destroy
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
37
research/chatwoot/app/models/concerns/sort_handler.rb
Normal file
37
research/chatwoot/app/models/concerns/sort_handler.rb
Normal 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
|
||||
32
research/chatwoot/app/models/concerns/sso_authenticatable.rb
Normal file
32
research/chatwoot/app/models/concerns/sso_authenticatable.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
253
research/chatwoot/app/models/contact.rb
Normal file
253
research/chatwoot/app/models/contact.rb
Normal 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')
|
||||
79
research/chatwoot/app/models/contact_inbox.rb
Normal file
79
research/chatwoot/app/models/contact_inbox.rb
Normal 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
|
||||
347
research/chatwoot/app/models/conversation.rb
Normal file
347
research/chatwoot/app/models/conversation.rb
Normal 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')
|
||||
41
research/chatwoot/app/models/conversation_participant.rb
Normal file
41
research/chatwoot/app/models/conversation_participant.rb
Normal 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
|
||||
43
research/chatwoot/app/models/csat_survey_response.rb
Normal file
43
research/chatwoot/app/models/csat_survey_response.rb
Normal 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
|
||||
67
research/chatwoot/app/models/custom_attribute_definition.rb
Normal file
67
research/chatwoot/app/models/custom_attribute_definition.rb
Normal 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')
|
||||
31
research/chatwoot/app/models/custom_filter.rb
Normal file
31
research/chatwoot/app/models/custom_filter.rb
Normal 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
|
||||
47
research/chatwoot/app/models/dashboard_app.rb
Normal file
47
research/chatwoot/app/models/dashboard_app.rb
Normal 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
|
||||
35
research/chatwoot/app/models/data_import.rb
Normal file
35
research/chatwoot/app/models/data_import.rb
Normal 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
|
||||
28
research/chatwoot/app/models/email_template.rb
Normal file
28
research/chatwoot/app/models/email_template.rb
Normal 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
|
||||
20
research/chatwoot/app/models/folder.rb
Normal file
20
research/chatwoot/app/models/folder.rb
Normal 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
|
||||
251
research/chatwoot/app/models/inbox.rb
Normal file
251
research/chatwoot/app/models/inbox.rb
Normal 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')
|
||||
21
research/chatwoot/app/models/inbox_assignment_policy.rb
Normal file
21
research/chatwoot/app/models/inbox_assignment_policy.rb
Normal 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
|
||||
39
research/chatwoot/app/models/inbox_member.rb
Normal file
39
research/chatwoot/app/models/inbox_member.rb
Normal 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')
|
||||
65
research/chatwoot/app/models/installation_config.rb
Normal file
65
research/chatwoot/app/models/installation_config.rb
Normal 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
|
||||
5
research/chatwoot/app/models/integrations.rb
Normal file
5
research/chatwoot/app/models/integrations.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
module Integrations
|
||||
def self.table_name_prefix
|
||||
'integrations_'
|
||||
end
|
||||
end
|
||||
129
research/chatwoot/app/models/integrations/app.rb
Normal file
129
research/chatwoot/app/models/integrations/app.rb
Normal 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
|
||||
110
research/chatwoot/app/models/integrations/hook.rb
Normal file
110
research/chatwoot/app/models/integrations/hook.rb
Normal 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
|
||||
@@ -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
|
||||
5
research/chatwoot/app/models/kbase.rb
Normal file
5
research/chatwoot/app/models/kbase.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
module Kbase
|
||||
def self.table_name_prefix
|
||||
'kbase_'
|
||||
end
|
||||
end
|
||||
56
research/chatwoot/app/models/label.rb
Normal file
56
research/chatwoot/app/models/label.rb
Normal 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
|
||||
78
research/chatwoot/app/models/macro.rb
Normal file
78
research/chatwoot/app/models/macro.rb
Normal 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')
|
||||
58
research/chatwoot/app/models/mention.rb
Normal file
58
research/chatwoot/app/models/mention.rb
Normal 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
|
||||
428
research/chatwoot/app/models/message.rb
Normal file
428
research/chatwoot/app/models/message.rb
Normal 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')
|
||||
36
research/chatwoot/app/models/note.rb
Normal file
36
research/chatwoot/app/models/note.rb
Normal 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
|
||||
208
research/chatwoot/app/models/notification.rb
Normal file
208
research/chatwoot/app/models/notification.rb
Normal 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
|
||||
35
research/chatwoot/app/models/notification_setting.rb
Normal file
35
research/chatwoot/app/models/notification_setting.rb
Normal 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
|
||||
29
research/chatwoot/app/models/notification_subscription.rb
Normal file
29
research/chatwoot/app/models/notification_subscription.rb
Normal 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
|
||||
16
research/chatwoot/app/models/platform_app.rb
Normal file
16
research/chatwoot/app/models/platform_app.rb
Normal 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
|
||||
24
research/chatwoot/app/models/platform_app_permissible.rb
Normal file
24
research/chatwoot/app/models/platform_app_permissible.rb
Normal 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
|
||||
74
research/chatwoot/app/models/portal.rb
Normal file
74
research/chatwoot/app/models/portal.rb
Normal 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')
|
||||
19
research/chatwoot/app/models/related_category.rb
Normal file
19
research/chatwoot/app/models/related_category.rb
Normal 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
|
||||
55
research/chatwoot/app/models/reporting_event.rb
Normal file
55
research/chatwoot/app/models/reporting_event.rb
Normal 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
|
||||
48
research/chatwoot/app/models/super_admin.rb
Normal file
48
research/chatwoot/app/models/super_admin.rb
Normal 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
|
||||
70
research/chatwoot/app/models/team.rb
Normal file
70
research/chatwoot/app/models/team.rb
Normal 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')
|
||||
23
research/chatwoot/app/models/team_member.rb
Normal file
23
research/chatwoot/app/models/team_member.rb
Normal 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')
|
||||
203
research/chatwoot/app/models/user.rb
Normal file
203
research/chatwoot/app/models/user.rb
Normal 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')
|
||||
43
research/chatwoot/app/models/webhook.rb
Normal file
43
research/chatwoot/app/models/webhook.rb
Normal 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')
|
||||
93
research/chatwoot/app/models/working_hour.rb
Normal file
93
research/chatwoot/app/models/working_hour.rb
Normal 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
|
||||
Reference in New Issue
Block a user