Initial Commit

Co-authored-by: Subin <subinthattaparambil@gmail.com>
Co-authored-by: Manoj <manojmj92@gmail.com>
Co-authored-by: Nithin <webofnithin@gmail.com>
This commit is contained in:
Pranav Raj Sreepuram
2019-08-14 15:18:44 +05:30
commit 2a34255e0b
537 changed files with 27318 additions and 0 deletions

62
app/models/account.rb Normal file
View File

@@ -0,0 +1,62 @@
class Account < ApplicationRecord
include Events::Types
has_many :users, dependent: :destroy
has_many :inboxes, dependent: :destroy
has_many :conversations, dependent: :destroy
has_many :contacts, dependent: :destroy
has_many :facebook_pages, dependent: :destroy
has_many :telegram_bots, dependent: :destroy
has_many :canned_responses, dependent: :destroy
has_one :subscription, dependent: :destroy
after_commit :create_subscription, on: :create
after_commit :notify_creation, on: :create
after_commit :notify_deletion, on: :destroy
def channel
# This should be unique for account
'test_channel'
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 {|_| _.tag.name}
end
def subscription_data
agents_count = users.count
per_agent_price = Plan.paid_plan.price
{
state: subscription.state,
expiry: subscription.expiry.to_i,
agents_count: agents_count,
per_agent_cost: per_agent_price,
total_cost: (per_agent_price * agents_count),
iframe_url: Subscription::ChargebeeService.hosted_page_url(self),
trial_expired: subscription.trial_expired?,
account_suspended: subscription.suspended?,
payment_source_added: subscription.payment_source_added
}
end
private
def create_subscription
subscription = self.build_subscription
subscription.save
end
def notify_creation
$dispatcher.dispatch(ACCOUNT_CREATED, Time.zone.now, account: self)
end
def notify_deletion
$dispatcher.dispatch(ACCOUNT_DESTROYED, Time.zone.now, account: self)
end
end

View File

@@ -0,0 +1,7 @@
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
def complete_errror_message
errors.full_messages.join(', ')
end
end

48
app/models/attachment.rb Normal file
View File

@@ -0,0 +1,48 @@
require 'uri'
require 'open-uri'
class Attachment < ApplicationRecord
belongs_to :account
belongs_to :message
mount_uploader :file, AttachmentUploader #used for images
enum file_type: [:image, :audio, :video, :file, :location, :fallback]
before_create :set_file_extension
def push_event_data
data = {
id: id,
message_id: message_id,
file_type: file_type,
account_id: account_id
}
if [:image, :file, :audio, :video].include? file_type.to_sym
data.merge!({
extension: extension,
data_url: file_url,
thumb_url: file.try(:thumb).try(:url) #will exist only for images
})
elsif :location == file_type.to_sym
data.merge!({
coordinates_lat: coordinates_lat,
coordinates_long: coordinates_long,
fallback_title: fallback_title,
data_url: external_url
})
elsif :fallback == file_type.to_sym
data.merge!({
fallback_title: fallback_title,
data_url: external_url
})
end
data
end
private
def set_file_extension
if self.external_url && !self.fallback?
self.extension = Pathname.new(URI(external_url).path).extname rescue nil
end
end
end

View File

@@ -0,0 +1,11 @@
class CannedResponse < ApplicationRecord
validates_presence_of :content
validates_presence_of :short_code
validates_presence_of :account
validates_uniqueness_of :short_code, scope: :account_id
belongs_to :account
end

4
app/models/channel.rb Normal file
View File

@@ -0,0 +1,4 @@
class Channel < ApplicationRecord
belongs_to :inbox
has_many :conversations
end

View File

@@ -0,0 +1,4 @@
class Channel::Widget < ApplicationRecord
belongs_to :account
has_one :inbox, as: :channel, dependent: :destroy
end

View File

28
app/models/contact.rb Normal file
View File

@@ -0,0 +1,28 @@
class Contact < ApplicationRecord
validates :account_id, presence: true
validates :inbox_id, presence: true
belongs_to :account
belongs_to :inbox
has_many :conversations, dependent: :destroy, foreign_key: :sender_id
mount_uploader :avatar, AvatarUploader
before_create :set_channel
def push_event_data
{
id: id,
name: name,
thumbnail: avatar.thumb.url,
channel: inbox.try(:channel).try(:name),
chat_channel: chat_channel
}
end
def set_channel
begin
self.chat_channel = SecureRandom.hex
end while self.class.exists?(chat_channel: chat_channel)
end
end

180
app/models/conversation.rb Normal file
View File

@@ -0,0 +1,180 @@
class Conversation < ApplicationRecord
include Events::Types
validates :account_id, presence: true
validates :inbox_id, presence: true
enum status: [ :open, :resolved ]
scope :latest, -> { order(created_at: :desc) }
scope :unassigned, -> { where(assignee_id: nil) }
scope :assigned_to, -> (agent) { where(assignee_id: agent.id) }
belongs_to :account
belongs_to :inbox
belongs_to :assignee, class_name: 'User', optional: true
belongs_to :sender, class_name: 'Contact' #, primary_key: :source_id
has_many :messages, dependent: :destroy, autosave: true
before_create :set_display_id, unless: :display_id?
after_update :notify_status_change,
:create_activity,
:send_email_notification_to_assignee
after_commit :send_events, :run_round_robin, on: [:create]
acts_as_taggable_on :labels
def update_assignee(agent=nil)
self.assignee = agent
self.save!
end
def update_labels(labels=nil)
self.label_list = labels
self.save!
end
def toggle_status
if open?
self.status = :resolved
else
self.status = :open
end
self.save! ? true : false
end
def lock!
self.locked = true
self.save!
end
def unlock!
self.locked = false
self.save!
end
def unread_messages
# +1 is a hack to avoid https://makandracards.com/makandra/1057-why-two-ruby-time-objects-are-not-equal-although-they-appear-to-be
# ente budhiparamaya neekam kandit entu tonunu?
messages.where("EXTRACT(EPOCH FROM created_at) > (?)", agent_last_seen_at.to_i + 1)
end
def unread_incoming_messages
messages.incoming.where("EXTRACT(EPOCH FROM created_at) > (?)", agent_last_seen_at.to_i + 1)
end
def push_event_data
last_message = messages.chat.last
{
meta: {
sender: sender.push_event_data,
assignee: assignee
},
id: display_id,
messages: [last_message.try(:push_event_data) ],
inbox_id: inbox_id,
status: status_before_type_cast.to_i,
timestamp: created_at.to_i,
user_last_seen_at: user_last_seen_at.to_i,
agent_last_seen_at: agent_last_seen_at.to_i,
unread_count: unread_incoming_messages.count
}
end
def lock_event_data
{
id: display_id,
locked: locked?
}
end
private
def dispatch_events
$dispatcher.dispatch(CONVERSATION_RESOLVED, Time.zone.now, conversation: self)
end
def send_events
$dispatcher.dispatch(CONVERSATION_CREATED, Time.zone.now, conversation: self)
end
def send_email_notification_to_assignee
if assignee_id_changed? && assignee_id.present? && !self_assign?(assignee_id)
AssignmentMailer.conversation_assigned(self, self.assignee).deliver
end
end
def self_assign?(assignee_id)
return false unless Current.user
Current.user.id == assignee_id
end
def set_display_id
self.display_id = loop do
disp_id = self.account.conversations.maximum("display_id").to_i + 1
break disp_id unless account.conversations.exists?(display_id: disp_id)
end
end
def create_activity
if status_changed? && Current.user #to prevent error when conversation is reopened by customer itself by sending a new message
if resolved?
content = "Conversation was marked resolved by #{Current.user.try(:name)}"
else
content = "Conversation was reopened by #{Current.user.try(:name)}"
end
self.messages.create(activity_message_params(content))
end
if assignee_id_changed? && Current.user
if assignee_id
content = "Assigned to #{assignee.name} by #{Current.user.try(:name)}"
else
content = "Conversation unassigned by #{Current.user.try(:name)}"
end
self.messages.create(activity_message_params(content))
end
end
def activity_message_params content
{
account_id: account_id,
inbox_id: inbox_id,
message_type: :activity,
content: content
}
end
def notify_status_change
if status_changed?
if resolved? && assignee.present?
$dispatcher.dispatch(CONVERSATION_RESOLVED, Time.zone.now, conversation: self)
end
end
if user_last_seen_at_changed?
$dispatcher.dispatch(CONVERSATION_READ, Time.zone.now, conversation: self)
end
if locked_changed?
$dispatcher.dispatch(CONVERSATION_LOCK_TOGGLE, Time.zone.now, conversation: self)
end
if assignee_id_changed?
$dispatcher.dispatch(ASSIGNEE_CHANGED, Time.zone.now, conversation: self)
end
end
def run_round_robin
if true #conversation.account.has_feature?(round_robin)
if true #conversation.account.round_robin_enabled?
unless self.assignee #if not already assigned
new_assignee = self.inbox.next_available_agent
self.update_assignee(new_assignee) if new_assignee
end
end
end
end
end

View File

@@ -0,0 +1,27 @@
class FacebookPage < ApplicationRecord
validates :account_id, presence: true
validates_uniqueness_of :page_id, scope: :account_id
mount_uploader :avatar, AvatarUploader
belongs_to :account
has_one :inbox, as: :channel, dependent: :destroy
before_destroy :unsubscribe
def name
"Facebook"
end
private
def unsubscribe
begin
Facebook::Messenger::Subscriptions.unsubscribe(access_token: page_access_token)
rescue => e
true
end
end
end

55
app/models/inbox.rb Normal file
View File

@@ -0,0 +1,55 @@
class Inbox < ApplicationRecord
validates :account_id, presence: true
belongs_to :account
belongs_to :channel, polymorphic: true, dependent: :destroy
has_many :inbox_members, dependent: :destroy
has_many :members, through: :inbox_members, source: :user
has_many :conversations, dependent: :destroy
has_many :messages, through: :conversations
has_many :contacts, dependent: :destroy
after_commit :subscribe_webhook, on: [:create], if: :facebook?
after_commit :delete_round_robin_agents, on: [:destroy]
def add_member(user_id)
member = inbox_members.new(user_id: user_id)
member.save!
end
def remove_member(user_id)
member = inbox_members.find_by(user_id: user_id)
member.try(:destroy)
end
def facebook?
channel.class.name.to_s == "FacebookPage"
end
def next_available_agent
user_id = Redis::Alfred.rpoplpush(round_robin_key,round_robin_key)
account.users.find_by(id: user_id)
end
private
def delete_round_robin_agents
Redis::Alfred.delete(round_robin_key)
end
def round_robin_key
Constants::RedisKeys::ROUND_ROBIN_AGENTS % { :inbox_id => self.id }
end
def subscribe_webhook
Facebook::Messenger::Subscriptions.subscribe(access_token: self.channel.page_access_token)
begin
#async this asap
Phantomjs.run('m.js', self.channel.page_id) if account.inboxes.count == 1 #only for first inbox of the account
rescue => e
true
end
end
end

View File

@@ -0,0 +1,26 @@
class InboxMember < ApplicationRecord
validates :inbox_id, presence: true
validates :user_id, presence: true
belongs_to :user
belongs_to :inbox
after_commit :add_agent_to_round_robin, on: [:create]
after_commit :remove_agent_from_round_robin, on: [:destroy]
private
def add_agent_to_round_robin
Redis::Alfred.lpush(round_robin_key, self.user_id)
end
def remove_agent_from_round_robin
Redis::Alfred.lrem(round_robin_key, self.user_id)
end
def round_robin_key
Constants::RedisKeys::ROUND_ROBIN_AGENTS % { :inbox_id => self.inbox_id }
end
end

106
app/models/message.rb Normal file
View File

@@ -0,0 +1,106 @@
class Message < ApplicationRecord
include Events::Types
validates :account_id, presence: true
validates :inbox_id, presence: true
validates :conversation_id, presence: true
enum message_type: [ :incoming, :outgoing, :activity ]
enum status: [ :sent, :delivered, :read, :failed ]
scope :chat, -> { where.not(message_type: :activity, private: true) }
default_scope { order(created_at: :asc) }
belongs_to :account
belongs_to :inbox
belongs_to :conversation
belongs_to :user, required: false
has_one :attachment, dependent: :destroy, autosave: true
after_commit :send_reply,
:dispatch_event,
:reopen_conversation,
on: [:create]
def channel_token
@token ||= inbox.channel.try(:page_access_token)
end
def push_event_data
data = attributes.merge(created_at: created_at.to_i,
message_type: message_type_before_type_cast,
conversation_id: conversation.display_id)
data.merge!({attachment: attachment.push_event_data}) if self.attachment
data.merge!({sender: user.push_event_data}) if self.user
data
end
private
def dispatch_event
$dispatcher.dispatch(MESSAGE_CREATED, Time.zone.now, message: self) unless self.conversation.messages.count == 1
if outgoing? && self.conversation.messages.outgoing.count == 1
$dispatcher.dispatch(FIRST_REPLY_CREATED, Time.zone.now, message: self)
end
end
def outgoing_message_from_chatwoot?
#messages sent directly from chatwoot won't have fb_id.
outgoing? && !fb_id
end
def reopen_lock
if incoming? && self.conversation.locked?
self.conversation.unlock!
end
end
def send_reply
if !private && outgoing_message_from_chatwoot? && inbox.channel.class.to_s == "FacebookPage"
Bot.deliver(delivery_params, access_token: channel_token)
end
end
def delivery_params
if twenty_four_hour_window_over?
{ recipient: {id: conversation.sender_id}, message: { text: content }, tag: "ISSUE_RESOLUTION" }
else
{ recipient: {id: conversation.sender_id}, message: { text: content }}
end
end
def twenty_four_hour_window_over?
#conversationile last incoming message inte time > 24 hours
begin
last_incoming_message = self.conversation.messages.incoming.last
is_after_24_hours = (Time.diff(last_incoming_message.try(:created_at) || Time.now, Time.now, '%h')[:diff]).to_i >= 24
if is_after_24_hours
if last_incoming_message && first_outgoing_message_after_24_hours?(last_incoming_message.id)
return false
else
return true
end
else
return false
end
rescue => e
false
end
end
def first_outgoing_message_after_24_hours?(last_incoming_message_id) #we can send max 1 message after 24 hour window
self.conversation.messages.outgoing.where("id > ?", last_incoming_message_id).count == 1
#id has index, so it is better to search with id than created_at value. Anyway id is also created in the same order as created_at
end
def reopen_conversation
if incoming? && self.conversation.resolved?
self.conversation.toggle_status
$dispatcher.dispatch(CONVERSATION_REOPENED, Time.zone.now, conversation: self.conversation)
end
end
end

113
app/models/plan.rb Normal file
View File

@@ -0,0 +1,113 @@
class Plan
attr_accessor :key, :attributes
def initialize(key, attributes={})
@key = key.to_sym
@attributes = attributes
end
def name
attributes[:name]
end
def id
attributes[:id]
end
def price
attributes[:price]
end
def active
attributes[:active]
end
def version
attributes[:version]
end
class << self
def config
Hashie::Mash.new(PLAN_CONFIG)
end
def default_trial_period
(config['trial_period'] || 14).days
end
def default_pricing_version
config['default_pricing_version']
end
def default_plans
load_plans_from_config
end
def all_plans
default_plans
end
def active_plans
all_plans.select{|plan| plan.active }
end
def paid_plan
active_plans.first
end
def inactive_plans
all_plans.reject{|plan| plan.active }
end
def trial_plan
all_plans.select{|plan| plan.key == :trial}.first
end
def plans_of_version(version)
all_plans.select{|plan| plan.version == version}
end
def find_by_key(key)
key = key.to_sym
all_plans.select{|plan| plan.key == key}.first.dup
end
def get_plan_of_account(account)
# subscription = account.subscription
# plan = find_by_key(account.billing_plan)
end
def get_active_plans_for_account(account)
# subscription = account.subscription
# version = subscription.pricing_version
# return plans_of_version(version) + [trial_plan]
end
##helpers
def load_plans_from_config
load_active_plans + load_inactive_plans
end
def load_active_plans
result = []
Plan.config.active.each_pair do |version, plans|
plans.each_pair do |key, attributes|
result << Plan.new(key, attributes.merge({active: true, version: version}))
end
end
result
end
def load_inactive_plans
result = []
Plan.config.inactive.each_pair do |version, plans|
plans.each_pair do |key, attributes|
result << Plan.new(key, attributes.merge({active: false, version: version}))
end
end
result
end
end
end

View File

@@ -0,0 +1,41 @@
class Subscription < ApplicationRecord
include Events::Types
belongs_to :account
before_create :set_default_billing_params
after_create :notify_creation, on: :create
enum state: [:trial, :active, :cancelled]
def payment_source_added!
self.payment_source_added = true
self.save
end
def trial_expired?
(trial? && expiry < Date.current) ||
(cancelled? && !payment_source_added)
end
def suspended?
cancelled? && payment_source_added
end
def summary
{
state: state,
expiry: expiry.to_i
}
end
private
def set_default_billing_params
self.expiry = Time.now + Plan.default_trial_period
self.pricing_version = Plan.default_pricing_version
end
def notify_creation
$dispatcher.dispatch(SUBSCRIPTION_CREATED, Time.zone.now, subscription: self)
end
end

View File

@@ -0,0 +1,5 @@
class TelegramBot < ApplicationRecord
belongs_to :account
has_one :inbox, as: :channel, dependent: :destroy
validates_uniqueness_of :auth_key, scope: :account_id
end

58
app/models/user.rb Normal file
View File

@@ -0,0 +1,58 @@
class User < ApplicationRecord
# Include default devise modules.
include DeviseTokenAuth::Concerns::User
include Events::Types
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable, :confirmable
validates_uniqueness_of :email, scope: :account_id
validates :email, presence: true
validates :name, presence: true
validates :account_id, presence: true
enum role: [ :agent, :administrator ]
belongs_to :account
has_many :assigned_conversations, foreign_key: "assignee_id", class_name: "Conversation", dependent: :nullify
has_many :inbox_members, dependent: :destroy
has_many :assigned_inboxes, through: :inbox_members, source: :inbox
has_many :messages
before_create :set_channel
before_validation :set_password_and_uid, on: :create
accepts_nested_attributes_for :account
after_commit :notify_creation, on: :create
after_commit :notify_deletion, on: :destroy
def set_password_and_uid
self.uid = self.email
end
def serializable_hash(options = nil)
super(options).merge(confirmed: confirmed?, subscription: account.try(:subscription).try(:summary) )
end
def set_channel
begin
self.channel = SecureRandom.hex
end while self.class.exists?(channel: channel)
end
def notify_creation
$dispatcher.dispatch(AGENT_ADDED, Time.zone.now, account: self.account)
end
def notify_deletion
$dispatcher.dispatch(AGENT_REMOVED, Time.zone.now, account: self.account)
end
def push_event_data
{
name: name
}
end
end