Restructure omni services and add Chatwoot research snapshot

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

View File

@@ -0,0 +1,36 @@
# frozen_string_literal: true
class Account::SignUpEmailValidationService
include CustomExceptions::Account
attr_reader :email
def initialize(email)
@email = email
end
def perform
address = ValidEmail2::Address.new(email)
raise InvalidEmail.new({ valid: false, disposable: nil }) unless address.valid?
raise InvalidEmail.new({ domain_blocked: true }) if domain_blocked?
raise InvalidEmail.new({ valid: true, disposable: true }) if address.disposable?
true
end
private
def domain_blocked?
domain = email.split('@').last&.downcase
blocked_domains.any? { |blocked_domain| domain.match?(blocked_domain.downcase) }
end
def blocked_domains
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
return [] if domains.blank?
domains.split("\n").map(&:strip)
end
end

View File

@@ -0,0 +1,52 @@
class AccountDeletionService
SOFT_DELETE_EMAIL_DOMAIN = '@chatwoot-deleted.invalid'.freeze
attr_reader :account, :soft_deleted_users
def initialize(account:)
@account = account
@soft_deleted_users = []
end
def perform
Rails.logger.info("Deleting account #{account.id} - #{account.name} that was marked for deletion")
soft_delete_orphaned_users
send_compliance_notification
DeleteObjectJob.perform_later(account)
end
private
def send_compliance_notification
AdministratorNotifications::AccountComplianceMailer.with(
account: account,
soft_deleted_users: soft_deleted_users
).account_deleted(account).deliver_later
end
def soft_delete_orphaned_users
account.users.each do |user|
# Skip users who are still associated with another account.
next if user.account_users.where.not(account_id: account.id).exists?
original_email = user.email
user.email = soft_deleted_email_for(user)
user.skip_reconfirmation!
user.save!
user_info = {
id: user.id.to_s,
original_email: original_email
}
soft_deleted_users << user_info
Rails.logger.info("Soft deleted user #{user.id} with email #{original_email}")
end
end
def soft_deleted_email_for(user)
"#{user.id}#{SOFT_DELETE_EMAIL_DOMAIN}"
end
end

View File

@@ -0,0 +1,111 @@
class ActionService
include EmailHelper
def initialize(conversation)
@conversation = conversation.reload
@account = @conversation.account
end
def mute_conversation(_params)
@conversation.mute!
end
def snooze_conversation(_params)
@conversation.snoozed!
end
def resolve_conversation(_params)
@conversation.resolved!
end
def open_conversation(_params)
@conversation.open!
end
def pending_conversation(_params)
@conversation.pending!
end
def change_status(status)
@conversation.update!(status: status[0])
end
def change_priority(priority)
@conversation.update!(priority: (priority[0] == 'nil' ? nil : priority[0]))
end
def add_label(labels)
return if labels.empty?
@conversation.reload.add_labels(labels)
end
def assign_agent(agent_ids = [])
return @conversation.update!(assignee_id: nil) if agent_ids[0] == 'nil'
return unless agent_belongs_to_inbox?(agent_ids)
@agent = @account.users.find_by(id: agent_ids)
@conversation.update!(assignee_id: @agent.id) if @agent.present?
end
def remove_label(labels)
return if labels.empty?
labels = @conversation.label_list - labels
@conversation.update(label_list: labels)
end
def assign_team(team_ids = [])
# FIXME: The explicit checks for zero or nil (string) is bad. Move
# this to a separate unassign action.
should_unassign = team_ids.blank? || %w[nil 0].include?(team_ids[0].to_s)
return @conversation.update!(team_id: nil) if should_unassign
# check if team belongs to account only if team_id is present
# if team_id is nil, then it means that the team is being unassigned
return unless !team_ids[0].nil? && team_belongs_to_account?(team_ids)
@conversation.update!(team_id: team_ids[0])
end
def remove_assigned_team(_params)
@conversation.update!(team_id: nil)
end
def send_email_transcript(emails)
return unless @account.email_transcript_enabled?
emails = emails[0].gsub(/\s+/, '').split(',')
emails.each do |email|
break unless @account.within_email_rate_limit?
email = parse_email_variables(@conversation, email)
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, email)&.deliver_later
@account.increment_email_sent_count
end
end
private
def agent_belongs_to_inbox?(agent_ids)
member_ids = @conversation.inbox.members.pluck(:user_id)
assignable_agent_ids = member_ids + @account.administrators.ids
assignable_agent_ids.include?(agent_ids[0])
end
def team_belongs_to_account?(team_ids)
@account.team_ids.include?(team_ids[0])
end
def conversation_a_tweet?
return false if @conversation.additional_attributes.blank?
@conversation.additional_attributes['type'] == 'tweet'
end
end
ActionService.include_mod_with('ActionService')

View File

@@ -0,0 +1,38 @@
class AutoAssignment::AgentAssignmentService
# Allowed agent ids: array
# This is the list of agents from which an agent can be assigned to this conversation
# examples: Agents with assignment capacity, Agents who are members of a team etc
pattr_initialize [:conversation!, :allowed_agent_ids!]
def find_assignee
round_robin_manage_service.available_agent(allowed_agent_ids: allowed_online_agent_ids)
end
def perform
new_assignee = find_assignee
conversation.update(assignee: new_assignee) if new_assignee
end
private
def online_agent_ids
online_agents = OnlineStatusTracker.get_available_users(conversation.account_id)
online_agents.select { |_key, value| value.eql?('online') }.keys if online_agents.present?
end
def allowed_online_agent_ids
# We want to perform roundrobin only over online agents
# Hence taking an intersection of online agents and allowed member ids
# the online user ids are string, since its from redis, allowed member ids are integer, since its from active record
@allowed_online_agent_ids ||= online_agent_ids & allowed_agent_ids&.map(&:to_s)
end
def round_robin_manage_service
@round_robin_manage_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: conversation.inbox)
end
def round_robin_key
format(::Redis::Alfred::ROUND_ROBIN_AGENTS, inbox_id: conversation.inbox_id)
end
end

View File

@@ -0,0 +1,102 @@
class AutoAssignment::AssignmentService
pattr_initialize [:inbox!]
def perform_bulk_assignment(limit: 100)
return 0 unless inbox.auto_assignment_v2_enabled?
return 0 unless inbox.enable_auto_assignment?
assigned_count = 0
unassigned_conversations(limit).each do |conversation|
assigned_count += 1 if perform_for_conversation(conversation)
end
assigned_count
end
private
def perform_for_conversation(conversation)
return false unless assignable?(conversation)
agent = find_available_agent(conversation)
return false unless agent
assign_conversation(conversation, agent)
end
def assignable?(conversation)
conversation.status == 'open' &&
conversation.assignee_id.nil?
end
def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open
# Apply conversation priority using assignment policy if available
policy = inbox.assignment_policy
scope = if policy&.longest_waiting?
scope.reorder(last_activity_at: :asc, created_at: :asc)
else
scope.reorder(created_at: :asc)
end
scope.limit(limit)
end
def find_available_agent(conversation = nil)
agents = filter_agents_by_team(inbox.available_agents, conversation)
return nil if agents.nil?
agents = filter_agents_by_rate_limit(agents)
return nil if agents.empty?
round_robin_selector.select_agent(agents)
end
def filter_agents_by_team(agents, conversation)
return agents if conversation&.team_id.blank?
team = conversation.team
return nil if team.blank? || team.allow_auto_assign.blank?
team_member_ids = team.members.ids
agents.where(user_id: team_member_ids)
end
def filter_agents_by_rate_limit(agents)
agents.select do |agent_member|
rate_limiter = build_rate_limiter(agent_member.user)
rate_limiter.within_limit?
end
end
def assign_conversation(conversation, agent)
conversation.update!(assignee: agent)
rate_limiter = build_rate_limiter(agent)
rate_limiter.track_assignment(conversation)
dispatch_assignment_event(conversation, agent)
true
end
def dispatch_assignment_event(conversation, agent)
Rails.configuration.dispatcher.dispatch(
Events::Types::ASSIGNEE_CHANGED,
Time.zone.now,
conversation: conversation,
user: agent
)
end
def build_rate_limiter(agent)
AutoAssignment::RateLimiter.new(inbox: inbox, agent: agent)
end
def round_robin_selector
@round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
end
end
AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService')

View File

@@ -0,0 +1,62 @@
class AutoAssignment::InboxRoundRobinService
pattr_initialize [:inbox!]
# called on inbox delete
def clear_queue
::Redis::Alfred.delete(round_robin_key)
end
# called on inbox member create
def add_agent_to_queue(user_id)
::Redis::Alfred.lpush(round_robin_key, user_id)
end
# called on inbox member delete
def remove_agent_from_queue(user_id)
::Redis::Alfred.lrem(round_robin_key, user_id)
end
def reset_queue
clear_queue
add_agent_to_queue(inbox.inbox_members.map(&:user_id))
end
# end of queue management functions
# allowed member ids = [assignable online agents supplied by the assignment service]
# the values of allowed member ids should be in string format
def available_agent(allowed_agent_ids: [])
reset_queue unless validate_queue?
user_id = get_member_from_allowed_agent_ids(allowed_agent_ids)
inbox.inbox_members.find_by(user_id: user_id)&.user if user_id.present?
end
private
def get_member_from_allowed_agent_ids(allowed_agent_ids)
return nil if allowed_agent_ids.blank?
user_id = queue.intersection(allowed_agent_ids).pop
pop_push_to_queue(user_id)
user_id
end
def pop_push_to_queue(user_id)
return if user_id.blank?
remove_agent_from_queue(user_id)
add_agent_to_queue(user_id)
end
def validate_queue?
return true if inbox.inbox_members.map(&:user_id).sort == queue.map(&:to_i).sort
end
def queue
::Redis::Alfred.lrange(round_robin_key)
end
def round_robin_key
format(::Redis::Alfred::ROUND_ROBIN_AGENTS, inbox_id: inbox.id)
end
end

View File

@@ -0,0 +1,47 @@
class AutoAssignment::RateLimiter
pattr_initialize [:inbox!, :agent!]
def within_limit?
return true unless enabled?
current_count < limit
end
def track_assignment(conversation)
assignment_key = build_assignment_key(conversation.id)
Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window)
end
def current_count
return 0 unless enabled?
pattern = assignment_key_pattern
Redis::Alfred.keys_count(pattern)
end
private
def enabled?
config.present? && limit.positive?
end
def limit
config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : Float::INFINITY
end
def window
config&.fair_distribution_window&.to_i || 24.hours.to_i
end
def config
@config ||= inbox.assignment_policy
end
def assignment_key_pattern
format(Redis::RedisKeys::ASSIGNMENT_KEY_PATTERN, inbox_id: inbox.id, agent_id: agent.id)
end
def build_assignment_key(conversation_id)
format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation_id)
end
end

View File

@@ -0,0 +1,16 @@
class AutoAssignment::RoundRobinSelector
pattr_initialize [:inbox!]
def select_agent(available_agents)
return nil if available_agents.empty?
agent_user_ids = available_agents.map(&:user_id).map(&:to_s)
round_robin_service.available_agent(allowed_agent_ids: agent_user_ids)
end
private
def round_robin_service
@round_robin_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: inbox)
end
end

View File

@@ -0,0 +1,67 @@
class AutomationRules::ActionService < ActionService
def initialize(rule, account, conversation)
super(conversation)
@rule = rule
@account = account
Current.executed_by = rule
end
def perform
@rule.actions.each do |action|
@conversation.reload
action = action.with_indifferent_access
begin
send(action[:action_name], action[:action_params])
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception
end
end
ensure
Current.reset
end
private
def send_attachment(blob_ids)
return if conversation_a_tweet?
return unless @rule.files.attached?
blobs = ActiveStorage::Blob.where(id: blob_ids)
return if blobs.blank?
params = { content: nil, private: false, attachments: blobs }
Messages::MessageBuilder.new(nil, @conversation, params).perform
end
def send_webhook_event(webhook_url)
payload = @conversation.webhook_data.merge(event: "automation_event.#{@rule.event_name}")
WebhookJob.perform_later(webhook_url[0], payload)
end
def send_message(message)
return if conversation_a_tweet?
params = { content: message[0], private: false, content_attributes: { automation_rule_id: @rule.id } }
Messages::MessageBuilder.new(nil, @conversation, params).perform
end
def add_private_note(message)
return if conversation_a_tweet?
params = { content: message[0], private: true, content_attributes: { automation_rule_id: @rule.id } }
Messages::MessageBuilder.new(nil, @conversation.reload, params).perform
end
def send_email_to_team(params)
teams = Team.where(id: params[0][:team_ids])
teams.each do |team|
break unless @account.within_email_rate_limit?
TeamNotifications::AutomationNotificationMailer.conversation_creation(@conversation, team, params[0][:message])&.deliver_now
@account.increment_email_sent_count
end
end
end

View File

@@ -0,0 +1,65 @@
class AutomationRules::ConditionValidationService
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
def initialize(rule)
@rule = rule
@account = rule.account
file = File.read('./lib/filters/filter_keys.yml')
@filters = YAML.safe_load(file)
@conversation_filters = @filters['conversations']
@contact_filters = @filters['contacts']
@message_filters = @filters['messages']
end
def perform
@rule.conditions.each do |condition|
return false unless valid_condition?(condition) && valid_query_operator?(condition)
end
true
end
private
def valid_query_operator?(condition)
query_operator = condition['query_operator']
return true if query_operator.nil?
return true if query_operator.empty?
%w[AND OR].include?(query_operator.upcase)
end
def valid_condition?(condition)
key = condition['attribute_key']
conversation_filter = @conversation_filters[key]
contact_filter = @contact_filters[key]
message_filter = @message_filters[key]
if conversation_filter || contact_filter || message_filter
operation_valid?(condition, conversation_filter || contact_filter || message_filter)
else
custom_attribute_present?(key, condition['custom_attribute_type'])
end
end
def operation_valid?(condition, filter)
filter_operator = condition['filter_operator']
# attribute changed is a special case
return true if filter_operator == 'attribute_changed'
filter['filter_operators'].include?(filter_operator)
end
def custom_attribute_present?(attribute_key, attribute_model)
attribute_model = attribute_model.presence || self.class::ATTRIBUTE_MODEL
@account.custom_attribute_definitions.where(
attribute_model: attribute_model
).find_by(attribute_key: attribute_key).present?
end
end

View File

@@ -0,0 +1,209 @@
require 'json'
class AutomationRules::ConditionsFilterService < FilterService
ATTRIBUTE_MODEL = 'contact_attribute'.freeze
def initialize(rule, conversation = nil, options = {})
super([], nil)
# assign rule, conversation and account to instance variables
@rule = rule
@conversation = conversation
@account = conversation.account
# setup filters from json file
file = File.read('./lib/filters/filter_keys.yml')
@filters = YAML.safe_load(file)
@conversation_filters = @filters['conversations']
@contact_filters = @filters['contacts']
@message_filters = @filters['messages']
@options = options
@changed_attributes = options[:changed_attributes]
end
def perform
return false unless rule_valid?
@attribute_changed_query_filter = []
@rule.conditions.each_with_index do |query_hash, current_index|
@attribute_changed_query_filter << query_hash and next if query_hash['filter_operator'] == 'attribute_changed'
apply_filter(query_hash, current_index)
end
records = base_relation.where(@query_string, @filter_values.with_indifferent_access)
records = perform_attribute_changed_filter(records) if @attribute_changed_query_filter.any?
records.any?
rescue StandardError => e
Rails.logger.error "Error in AutomationRules::ConditionsFilterService: #{e.message}"
Rails.logger.info "AutomationRules::ConditionsFilterService failed while processing rule #{@rule.id} for conversation #{@conversation.id}"
false
end
def rule_valid?
is_valid = AutomationRules::ConditionValidationService.new(@rule).perform
Rails.logger.info "Automation rule condition validation failed for rule id: #{@rule.id}" unless is_valid
@rule.authorization_error! unless is_valid
is_valid
end
def filter_operation(query_hash, current_index)
if query_hash[:filter_operator] == 'starts_with'
@filter_values["value_#{current_index}"] = "#{string_filter_values(query_hash)}%"
like_filter_string(query_hash[:filter_operator], current_index)
else
super
end
end
def apply_filter(query_hash, current_index)
conversation_filter = @conversation_filters[query_hash['attribute_key']]
contact_filter = @contact_filters[query_hash['attribute_key']]
message_filter = @message_filters[query_hash['attribute_key']]
if conversation_filter
@query_string += conversation_query_string('conversations', conversation_filter, query_hash.with_indifferent_access, current_index)
elsif contact_filter
@query_string += contact_query_string(contact_filter, query_hash.with_indifferent_access, current_index)
elsif message_filter
@query_string += message_query_string(message_filter, query_hash.with_indifferent_access, current_index)
elsif custom_attribute(query_hash['attribute_key'], @account, query_hash['custom_attribute_type'])
# send table name according to attribute key right now we are supporting contact based custom attribute filter
@query_string += custom_attribute_query(query_hash.with_indifferent_access, query_hash['custom_attribute_type'], current_index)
end
end
# If attribute_changed type filter is present perform this against array
def perform_attribute_changed_filter(records)
@attribute_changed_records = []
current_attribute_changed_record = base_relation
filter_based_on_attribute_change(records, current_attribute_changed_record)
@attribute_changed_records.uniq
end
# Loop through attribute_changed_query_filter
def filter_based_on_attribute_change(records, current_attribute_changed_record)
@attribute_changed_query_filter.each do |filter|
@changed_attributes = @changed_attributes.with_indifferent_access
changed_attribute = @changed_attributes[filter['attribute_key']].presence
if changed_attribute[0].in?(filter['values']['from']) && changed_attribute[1].in?(filter['values']['to'])
@attribute_changed_records = attribute_changed_filter_query(filter, records, current_attribute_changed_record)
end
current_attribute_changed_record = @attribute_changed_records
end
end
# We intersect with the record if query_operator-AND is present and union if query_operator-OR is present
def attribute_changed_filter_query(filter, records, current_attribute_changed_record)
if filter['query_operator'] == 'AND'
@attribute_changed_records + (current_attribute_changed_record & records)
else
@attribute_changed_records + (current_attribute_changed_record | records)
end
end
def message_query_string(current_filter, query_hash, current_index)
attribute_key = query_hash['attribute_key']
query_operator = query_hash['query_operator']
attribute_key = 'processed_message_content' if attribute_key == 'content'
filter_operator_value = filter_operation(query_hash, current_index)
case current_filter['attribute_type']
when 'standard'
if current_filter['data_type'] == 'text'
" LOWER(messages.#{attribute_key}) #{filter_operator_value} #{query_operator} "
else
" messages.#{attribute_key} #{filter_operator_value} #{query_operator} "
end
end
end
# This will be used in future for contact automation rule
def contact_query_string(current_filter, query_hash, current_index)
attribute_key = query_hash['attribute_key']
query_operator = query_hash['query_operator']
filter_operator_value = filter_operation(query_hash, current_index)
case current_filter['attribute_type']
when 'additional_attributes'
" contacts.additional_attributes ->> '#{attribute_key}' #{filter_operator_value} #{query_operator} "
when 'standard'
" contacts.#{attribute_key} #{filter_operator_value} #{query_operator} "
end
end
def conversation_query_string(table_name, current_filter, query_hash, current_index)
attribute_key = query_hash['attribute_key']
query_operator = query_hash['query_operator']
filter_operator_value = filter_operation(query_hash, current_index)
case current_filter['attribute_type']
when 'additional_attributes'
" #{table_name}.additional_attributes ->> '#{attribute_key}' #{filter_operator_value} #{query_operator} "
when 'standard'
if attribute_key == 'labels'
build_label_query_string(query_hash, current_index, query_operator)
else
" #{table_name}.#{attribute_key} #{filter_operator_value} #{query_operator} "
end
end
end
def build_label_query_string(query_hash, current_index, query_operator)
case query_hash['filter_operator']
when 'equal_to'
return " 1=0 #{query_operator} " if query_hash['values'].blank?
value_placeholder = "value_#{current_index}"
@filter_values[value_placeholder] = query_hash['values'].first
" tags.name = :#{value_placeholder} #{query_operator} "
when 'not_equal_to'
return " 1=0 #{query_operator} " if query_hash['values'].blank?
value_placeholder = "value_#{current_index}"
@filter_values[value_placeholder] = query_hash['values'].first
" tags.name != :#{value_placeholder} #{query_operator} "
when 'is_present'
" tags.id IS NOT NULL #{query_operator} "
when 'is_not_present'
" tags.id IS NULL #{query_operator} "
else
" tags.id #{filter_operation(query_hash, current_index)} #{query_operator} "
end
end
private
def base_relation
records = Conversation.where(id: @conversation.id).joins(
'LEFT OUTER JOIN contacts on conversations.contact_id = contacts.id'
).joins(
'LEFT OUTER JOIN messages on messages.conversation_id = conversations.id'
)
# Only add label joins when label conditions exist
if label_conditions?
records = records.joins(
'LEFT OUTER JOIN taggings ON taggings.taggable_id = conversations.id AND taggings.taggable_type = \'Conversation\''
).joins(
'LEFT OUTER JOIN tags ON taggings.tag_id = tags.id'
)
end
records = records.where(messages: { id: @options[:message].id }) if @options[:message].present?
records
end
def label_conditions?
@rule.conditions.any? { |condition| condition['attribute_key'] == 'labels' }
end
end

View File

@@ -0,0 +1,55 @@
#######################################
# To create an external channel reply service
# - Inherit this as the base class.
# - Implement `channel_class` method in your child class.
# - Implement `perform_reply` method in your child class.
# - Implement additional custom logic for your `perform_reply` method.
# - When required override the validation_methods.
# - Use Childclass.new.perform.
######################################
class Base::SendOnChannelService
pattr_initialize [:message!]
def perform
validate_target_channel
return unless outgoing_message?
return if invalid_message?
perform_reply
end
private
delegate :conversation, to: :message
delegate :contact, :contact_inbox, :inbox, to: :conversation
delegate :channel, to: :inbox
def channel_class
raise 'Overwrite this method in child class'
end
def perform_reply
raise 'Overwrite this method in child class'
end
def outgoing_message_originated_from_channel?
# TODO: we need to refactor this logic as more integrations comes by
# chatwoot messages won't have source id at the moment
# TODO: migrate source_ids to external_source_ids and check the source id relevant to specific channel
message.source_id.present?
end
def outgoing_message?
message.outgoing? || message.template?
end
def invalid_message?
# private notes aren't send to the channels
# we should also avoid the case of message loops, when outgoing messages are created from channel
message.private? || outgoing_message_originated_from_channel?
end
def validate_target_channel
raise 'Invalid channel service was called' if inbox.channel.class != channel_class
end
end

View File

@@ -0,0 +1,62 @@
class BaseRefreshOauthTokenService
pattr_initialize [:channel!]
# Additional references: https://gitlab.com/gitlab-org/ruby/gems/gitlab-mail_room/-/blob/master/lib/mail_room/microsoft_graph/connection.rb
def access_token
return provider_config[:access_token] unless access_token_expired?
refreshed_tokens = refresh_tokens
refreshed_tokens[:access_token]
end
def access_token_expired?
expiry = provider_config[:expires_on]
return true if expiry.blank?
# Adding a 5 minute window to expiry check to avoid any race
# conditions during the fetch operation. This would assure that the
# tokens are updated when we fetch the emails.
Time.current.utc >= DateTime.parse(expiry) - 5.minutes
end
# Refresh the access tokens using the refresh token
# Refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp/tree/b4a6869fe4a438cde42b161196484a929f1bee46
def refresh_tokens
oauth_strategy = build_oauth_strategy
token_service = build_token_service(oauth_strategy)
new_tokens = token_service.refresh!.to_hash.slice(:access_token, :refresh_token, :expires_at)
update_channel_provider_config(new_tokens)
channel.reload.provider_config
end
def update_channel_provider_config(new_tokens)
channel.provider_config = {
access_token: new_tokens[:access_token],
refresh_token: new_tokens[:refresh_token],
expires_on: Time.at(new_tokens[:expires_at]).utc.to_s
}
channel.save!
end
private
def build_oauth_strategy
raise NotImplementedError
end
def provider_config
@provider_config ||= channel.provider_config.with_indifferent_access
end
# Builds the token service using OAuth2
def build_token_service(oauth_strategy)
OAuth2::AccessToken.new(
oauth_strategy.client,
provider_config[:access_token],
refresh_token: provider_config[:refresh_token]
)
end
end

View File

@@ -0,0 +1,27 @@
class BaseTokenService
pattr_initialize [:payload, :token]
def generate_token
JWT.encode(token_payload, secret_key, algorithm)
end
def decode_token
JWT.decode(token, secret_key, true, algorithm: algorithm).first.symbolize_keys
rescue JWT::ExpiredSignature, JWT::DecodeError
{}
end
private
def token_payload
payload || {}
end
def secret_key
Rails.application.secret_key_base
end
def algorithm
'HS256'
end
end

View File

@@ -0,0 +1,44 @@
class Contacts::BulkActionService
def initialize(account:, user:, params:)
@account = account
@user = user
@params = params.deep_symbolize_keys
end
def perform
return delete_contacts if delete_requested?
return assign_labels if labels_to_add.any?
Rails.logger.warn("Unknown contact bulk operation payload: #{@params.keys}")
{ success: false, error: 'unknown_operation' }
end
private
def assign_labels
Contacts::BulkAssignLabelsService.new(
account: @account,
contact_ids: ids,
labels: labels_to_add
).perform
end
def delete_contacts
Contacts::BulkDeleteService.new(
account: @account,
contact_ids: ids
).perform
end
def ids
Array(@params[:ids]).compact
end
def labels_to_add
@labels_to_add ||= Array(@params.dig(:labels, :add)).reject(&:blank?)
end
def delete_requested?
@params[:action_name] == 'delete'
end
end

View File

@@ -0,0 +1,19 @@
class Contacts::BulkAssignLabelsService
def initialize(account:, contact_ids:, labels:)
@account = account
@contact_ids = Array(contact_ids)
@labels = Array(labels).compact_blank
end
def perform
return { success: true, updated_contact_ids: [] } if @contact_ids.blank? || @labels.blank?
contacts = @account.contacts.where(id: @contact_ids)
contacts.find_each do |contact|
contact.add_labels(@labels)
end
{ success: true, updated_contact_ids: contacts.pluck(:id) }
end
end

View File

@@ -0,0 +1,18 @@
class Contacts::BulkDeleteService
def initialize(account:, contact_ids: [])
@account = account
@contact_ids = Array(contact_ids).compact
end
def perform
return if @contact_ids.blank?
contacts.find_each(&:destroy!)
end
private
def contacts
@account.contacts.where(id: @contact_ids)
end
end

View File

@@ -0,0 +1,75 @@
class Contacts::ContactableInboxesService
pattr_initialize [:contact!]
def get
account = contact.account
account.inboxes.filter_map { |inbox| get_contactable_inbox(inbox) }
end
private
def get_contactable_inbox(inbox)
case inbox.channel_type
when 'Channel::TwilioSms'
twilio_contactable_inbox(inbox)
when 'Channel::Whatsapp'
whatsapp_contactable_inbox(inbox)
when 'Channel::Sms'
sms_contactable_inbox(inbox)
when 'Channel::Email'
email_contactable_inbox(inbox)
when 'Channel::Api'
api_contactable_inbox(inbox)
when 'Channel::WebWidget'
website_contactable_inbox(inbox)
end
end
def website_contactable_inbox(inbox)
latest_contact_inbox = inbox.contact_inboxes.where(contact: @contact).last
return unless latest_contact_inbox
# FIXME : change this when multiple conversations comes in
return if latest_contact_inbox.conversations.present?
{ source_id: latest_contact_inbox.source_id, inbox: inbox }
end
def api_contactable_inbox(inbox)
latest_contact_inbox = inbox.contact_inboxes.where(contact: @contact).last
source_id = latest_contact_inbox&.source_id || SecureRandom.uuid
{ source_id: source_id, inbox: inbox }
end
def email_contactable_inbox(inbox)
return if @contact.email.blank?
{ source_id: @contact.email, inbox: inbox }
end
def whatsapp_contactable_inbox(inbox)
return if @contact.phone_number.blank?
# Remove the plus since thats the format 360 dialog uses
{ source_id: @contact.phone_number.delete('+'), inbox: inbox }
end
def sms_contactable_inbox(inbox)
return if @contact.phone_number.blank?
{ source_id: @contact.phone_number, inbox: inbox }
end
def twilio_contactable_inbox(inbox)
return if @contact.phone_number.blank?
case inbox.channel.medium
when 'sms'
{ source_id: @contact.phone_number, inbox: inbox }
when 'whatsapp'
{ source_id: "whatsapp:#{@contact.phone_number}", inbox: inbox }
end
end
end
Contacts::ContactableInboxesService.prepend_mod_with('Contacts::ContactableInboxesService')

View File

@@ -0,0 +1,50 @@
class Contacts::FilterService < FilterService
ATTRIBUTE_MODEL = 'contact_attribute'.freeze
def initialize(account, user, params)
@account = account
# TODO: Change the order of arguments in FilterService maybe?
# account, user, params makes more sense
super(params, user)
end
def perform
validate_query_operator
@contacts = query_builder(@filters['contacts'])
{
contacts: @contacts,
count: @contacts.count
}
end
def filter_values(query_hash)
current_val = query_hash['values'][0]
if query_hash['attribute_key'] == 'phone_number'
"+#{current_val&.delete('+')}"
elsif query_hash['attribute_key'] == 'country_code'
current_val.downcase
else
current_val.is_a?(String) ? current_val.downcase : current_val
end
end
def base_relation
@account.contacts.resolved_contacts(use_crm_v2: @account.feature_enabled?('crm_v2'))
end
def filter_config
{
entity: 'Contact',
table_name: 'contacts'
}
end
private
def equals_to_filter_string(filter_operator, current_index)
return "= :value_#{current_index}" if filter_operator == 'equal_to'
"!= :value_#{current_index}"
end
end

View File

@@ -0,0 +1,37 @@
class Contacts::SyncAttributes
attr_reader :contact
def initialize(contact)
@contact = contact
end
def perform
update_contact_location_and_country_code
set_contact_type
end
private
def update_contact_location_and_country_code
# Ensure that location and country_code are updated from additional_attributes.
# TODO: Remove this once all contacts are updated and both the location and country_code fields are standardized throughout the app.
@contact.location = @contact.additional_attributes['city']
@contact.country_code = @contact.additional_attributes['country']
end
def set_contact_type
# If the contact is already a lead or customer then do not change the contact type
return unless @contact.contact_type == 'visitor'
# If the contact has an email or phone number or social details( facebook_user_id, instagram_user_id, etc) then it is a lead
# If contact is from external channel like facebook, instagram, whatsapp, etc then it is a lead
return unless @contact.email.present? || @contact.phone_number.present? || social_details_present?
@contact.contact_type = 'lead'
end
def social_details_present?
@contact.additional_attributes.keys.any? do |key|
key.start_with?('social_') && @contact.additional_attributes[key].present?
end
end
end

View File

@@ -0,0 +1,43 @@
class Conversations::AssignmentService
def initialize(conversation:, assignee_id:, assignee_type: nil)
@conversation = conversation
@assignee_id = assignee_id
@assignee_type = assignee_type
end
def perform
agent_bot_assignment? ? assign_agent_bot : assign_agent
end
private
attr_reader :conversation, :assignee_id, :assignee_type
def assign_agent
conversation.assignee = assignee
conversation.assignee_agent_bot = nil
conversation.save!
assignee
end
def assign_agent_bot
return unless agent_bot
conversation.assignee = nil
conversation.assignee_agent_bot = agent_bot
conversation.save!
agent_bot
end
def assignee
@assignee ||= conversation.account.users.find_by(id: assignee_id)
end
def agent_bot
@agent_bot ||= AgentBot.accessible_to(conversation.account).find_by(id: assignee_id)
end
def agent_bot_assignment?
assignee_type.to_s == 'AgentBot'
end
end

View File

@@ -0,0 +1,52 @@
class Conversations::FilterService < FilterService
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
def initialize(params, user, account)
@account = account
super(params, user)
end
def perform
validate_query_operator
@conversations = query_builder(@filters['conversations'])
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
{
conversations: conversations,
count: {
mine_count: mine_count,
assigned_count: assigned_count,
unassigned_count: unassigned_count,
all_count: all_count
}
}
end
def base_relation
conversations = @account.conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
)
Conversations::PermissionFilterService.new(
conversations,
@user,
@account
).perform
end
def current_page
@params[:page] || 1
end
def filter_config
{
entity: 'Conversation',
table_name: 'conversations'
}
end
def conversations
@conversations.sort_on_last_activity_at.page(current_page)
end
end

View File

@@ -0,0 +1,70 @@
class Conversations::MessageWindowService
MESSAGING_WINDOW_24_HOURS = 24.hours
MESSAGING_WINDOW_7_DAYS = 7.days
def initialize(conversation)
@conversation = conversation
end
def can_reply?
return true if messaging_window.blank?
last_message_in_messaging_window?(messaging_window)
end
private
def messaging_window
case @conversation.inbox.channel_type
when 'Channel::Api'
api_messaging_window
when 'Channel::FacebookPage'
messenger_messaging_window
when 'Channel::Instagram'
instagram_messaging_window
when 'Channel::Tiktok'
tiktok_messaging_window
when 'Channel::Whatsapp'
MESSAGING_WINDOW_24_HOURS
when 'Channel::TwilioSms'
twilio_messaging_window
end
end
def last_message_in_messaging_window?(time)
return false if last_incoming_message.nil?
Time.current < last_incoming_message.created_at + time
end
def api_messaging_window
return if @conversation.inbox.channel.additional_attributes['agent_reply_time_window'].blank?
@conversation.inbox.channel.additional_attributes['agent_reply_time_window'].to_i.hours
end
# Check medium of the inbox to determine the messaging window
def twilio_messaging_window
@conversation.inbox.channel.medium == 'whatsapp' ? MESSAGING_WINDOW_24_HOURS : nil
end
def messenger_messaging_window
meta_messaging_window('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
end
def instagram_messaging_window
meta_messaging_window('ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT')
end
def tiktok_messaging_window
48.hours
end
def meta_messaging_window(config_key)
GlobalConfigService.load(config_key, nil) ? MESSAGING_WINDOW_7_DAYS : MESSAGING_WINDOW_24_HOURS
end
def last_incoming_message
@last_incoming_message ||= @conversation.messages.where(account_id: @conversation.account_id).incoming&.last
end
end

View File

@@ -0,0 +1,31 @@
class Conversations::PermissionFilterService
attr_reader :conversations, :user, :account
def initialize(conversations, user, account)
@conversations = conversations
@user = user
@account = account
end
def perform
return conversations if user_role == 'administrator'
accessible_conversations
end
private
def accessible_conversations
conversations.where(inbox: user.inboxes.where(account_id: account.id))
end
def account_user
AccountUser.find_by(account_id: account.id, user_id: user.id)
end
def user_role
account_user&.role
end
end
Conversations::PermissionFilterService.prepend_mod_with('Conversations::PermissionFilterService')

View File

@@ -0,0 +1,26 @@
class Conversations::TypingStatusManager
include Events::Types
attr_reader :conversation, :user, :params
def initialize(conversation, user, params)
@conversation = conversation
@user = user
@params = params
end
def trigger_typing_event(event, is_private)
user = @user.presence || @resource
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: is_private)
end
def toggle_typing_status
case params[:typing_status]
when 'on'
trigger_typing_event(CONVERSATION_TYPING_ON, params[:is_private])
when 'off'
trigger_typing_event(CONVERSATION_TYPING_OFF, params[:is_private])
end
# Return the head :ok response from the controller
end
end

View File

@@ -0,0 +1,92 @@
class Crm::BaseProcessorService
def initialize(hook)
@hook = hook
@account = hook.account
end
# Class method to be overridden by subclasses
def self.crm_name
raise NotImplementedError, 'Subclasses must define self.crm_name'
end
# Instance method that calls the class method
def crm_name
self.class.crm_name
end
def process_event(event_name, event_data)
case event_name
when 'contact.created'
handle_contact_created(event_data)
when 'contact.updated'
handle_contact_updated(event_data)
when 'conversation.created'
handle_conversation_created(event_data)
when 'conversation.updated'
handle_conversation_updated(event_data)
else
{ success: false, error: "Unsupported event: #{event_name}" }
end
rescue StandardError => e
Rails.logger.error "#{crm_name} Processor Error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
{ success: false, error: e.message }
end
# Abstract methods that subclasses must implement
def handle_contact_created(contact)
raise NotImplementedError, 'Subclasses must implement #handle_contact_created'
end
def handle_contact_updated(contact)
raise NotImplementedError, 'Subclasses must implement #handle_contact_updated'
end
def handle_conversation_created(conversation)
raise NotImplementedError, 'Subclasses must implement #handle_conversation_created'
end
def handle_conversation_resolved(conversation)
raise NotImplementedError, 'Subclasses must implement #handle_conversation_resolved'
end
# Common helper methods for all CRM processors
protected
def identifiable_contact?(contact)
has_social_profile = contact.additional_attributes['social_profiles'].present?
contact.present? && (contact.email.present? || contact.phone_number.present? || has_social_profile)
end
def get_external_id(contact)
return nil if contact.additional_attributes.blank?
return nil if contact.additional_attributes['external'].blank?
contact.additional_attributes.dig('external', "#{crm_name}_id")
end
def store_external_id(contact, external_id)
# Initialize additional_attributes if it's nil
contact.additional_attributes = {} if contact.additional_attributes.nil?
# Initialize external hash if it doesn't exist
contact.additional_attributes['external'] = {} if contact.additional_attributes['external'].blank?
# Store the external ID
contact.additional_attributes['external']["#{crm_name}_id"] = external_id
contact.save!
end
def store_conversation_metadata(conversation, metadata)
# Initialize additional_attributes if it's nil
conversation.additional_attributes = {} if conversation.additional_attributes.nil?
# Initialize CRM-specific hash in additional_attributes
conversation.additional_attributes[crm_name] = {} if conversation.additional_attributes[crm_name].blank?
# Store the metadata
conversation.additional_attributes[crm_name].merge!(metadata)
conversation.save!
end
end

View File

@@ -0,0 +1,36 @@
class Crm::Leadsquared::Api::ActivityClient < Crm::Leadsquared::Api::BaseClient
# https://apidocs.leadsquared.com/post-an-activity-to-lead/#api
def post_activity(prospect_id, activity_event, activity_note)
raise ArgumentError, 'Prospect ID is required' if prospect_id.blank?
raise ArgumentError, 'Activity event code is required' if activity_event.blank?
path = 'ProspectActivity.svc/Create'
body = {
'RelatedProspectId' => prospect_id,
'ActivityEvent' => activity_event,
'ActivityNote' => activity_note
}
response = post(path, {}, body)
response['Message']['Id']
end
def create_activity_type(name:, score:, direction: 0)
raise ArgumentError, 'Activity name is required' if name.blank?
path = 'ProspectActivity.svc/CreateType'
body = {
'ActivityEventName' => name,
'Score' => score.to_i,
'Direction' => direction.to_i
}
response = post(path, {}, body)
response['Message']['Id']
end
def fetch_activity_types
get('ProspectActivity.svc/ActivityTypes.Get')
end
end

View File

@@ -0,0 +1,84 @@
class Crm::Leadsquared::Api::BaseClient
include HTTParty
class ApiError < StandardError
attr_reader :code, :response
def initialize(message = nil, code = nil, response = nil)
@code = code
@response = response
super(message)
end
end
def initialize(access_key, secret_key, endpoint_url)
@access_key = access_key
@secret_key = secret_key
@base_uri = endpoint_url
end
def get(path, params = {})
full_url = URI.join(@base_uri, path).to_s
options = {
query: params,
headers: headers
}
response = self.class.get(full_url, options)
handle_response(response)
end
def post(path, params = {}, body = {})
full_url = URI.join(@base_uri, path).to_s
options = {
query: params,
headers: headers
}
options[:body] = body.to_json if body.present?
response = self.class.post(full_url, options)
handle_response(response)
end
private
def headers
{
'Content-Type': 'application/json',
'x-LSQ-AccessKey': @access_key,
'x-LSQ-SecretKey': @secret_key
}
end
def handle_response(response)
case response.code
when 200..299
handle_success(response)
else
error_message = "LeadSquared API error: #{response.code} - #{response.body}"
Rails.logger.error error_message
raise ApiError.new(error_message, response.code, response)
end
end
def handle_success(response)
parse_response(response)
rescue JSON::ParserError, TypeError => e
error_message = "Failed to parse LeadSquared API response: #{e.message}"
raise ApiError.new(error_message, response.code, response)
end
def parse_response(response)
body = response.parsed_response
if body.is_a?(Hash) && body['Status'] == 'Error'
error_message = body['ExceptionMessage'] || 'Unknown API error'
raise ApiError.new(error_message, response.code, response)
else
body
end
end
end

View File

@@ -0,0 +1,50 @@
class Crm::Leadsquared::Api::LeadClient < Crm::Leadsquared::Api::BaseClient
# https://apidocs.leadsquared.com/quick-search/#api
def search_lead(key)
raise ArgumentError, 'Search key is required' if key.blank?
path = 'LeadManagement.svc/Leads.GetByQuickSearch'
params = { key: key }
get(path, params)
end
# https://apidocs.leadsquared.com/create-or-update/#api
# The email address and phone fields are used as the default search criteria.
# If none of these match with an existing lead, a new lead will be created.
# We can pass the "SearchBy" attribute in the JSON body to search by a particular parameter, however
# we don't need this capability at the moment
def create_or_update_lead(lead_data)
raise ArgumentError, 'Lead data is required' if lead_data.blank?
path = 'LeadManagement.svc/Lead.CreateOrUpdate'
formatted_data = format_lead_data(lead_data)
response = post(path, {}, formatted_data)
response['Message']['Id']
end
def update_lead(lead_data, lead_id)
raise ArgumentError, 'Lead ID is required' if lead_id.blank?
raise ArgumentError, 'Lead data is required' if lead_data.blank?
path = "LeadManagement.svc/Lead.Update?leadId=#{lead_id}"
formatted_data = format_lead_data(lead_data)
response = post(path, {}, formatted_data)
response['Message']['AffectedRows']
end
private
def format_lead_data(lead_data)
lead_data.map do |key, value|
{
'Attribute' => key,
'Value' => value
}
end
end
end

View File

@@ -0,0 +1,63 @@
class Crm::Leadsquared::LeadFinderService
def initialize(lead_client)
@lead_client = lead_client
end
def find_or_create(contact)
lead_id = get_stored_id(contact)
return lead_id if lead_id.present?
lead_id = find_by_contact(contact)
return lead_id if lead_id.present?
create_lead(contact)
end
private
def find_by_contact(contact)
lead_id = find_by_email(contact)
lead_id = find_by_phone_number(contact) if lead_id.blank?
lead_id
end
def find_by_email(contact)
return if contact.email.blank?
search_by_field(contact.email)
end
def find_by_phone_number(contact)
return if contact.phone_number.blank?
lead_data = Crm::Leadsquared::Mappers::ContactMapper.map(contact)
return if lead_data.blank? || lead_data['Mobile'].nil?
search_by_field(lead_data['Mobile'])
end
def search_by_field(value)
leads = @lead_client.search_lead(value)
return nil unless leads.is_a?(Array)
leads.first['ProspectID'] if leads.any?
end
def create_lead(contact)
lead_data = Crm::Leadsquared::Mappers::ContactMapper.map(contact)
lead_id = @lead_client.create_or_update_lead(lead_data)
raise StandardError, 'Failed to create lead - no ID returned' if lead_id.blank?
lead_id
end
def get_stored_id(contact)
return nil if contact.additional_attributes.blank?
return nil if contact.additional_attributes['external'].blank?
contact.additional_attributes.dig('external', 'leadsquared_id')
end
end

View File

@@ -0,0 +1,53 @@
class Crm::Leadsquared::Mappers::ContactMapper
def self.map(contact)
new(contact).map
end
def initialize(contact)
@contact = contact
end
def map
base_attributes
end
private
attr_reader :contact
def base_attributes
{
'FirstName' => contact.name.presence,
'LastName' => contact.last_name.presence,
'EmailAddress' => contact.email.presence,
'Mobile' => formatted_phone_number,
'Source' => brand_name
}.compact
end
def formatted_phone_number
# it seems like leadsquared needs a different phone number format
# it's not documented anywhere, so don't bother trying to look up online
# After some trial and error, I figured out the format, its +<country_code>-<national_number>
return nil if contact.phone_number.blank?
parsed = TelephoneNumber.parse(contact.phone_number)
return contact.phone_number unless parsed.valid?
country_code = parsed.country.country_code
e164 = parsed.e164_number
e164 = e164.sub(/^\+/, '')
national_number = e164.sub(/^#{Regexp.escape(country_code)}/, '')
"+#{country_code}-#{national_number}"
end
def brand_name
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'] || 'Chatwoot'
end
def brand_name_without_spaces
brand_name.gsub(/\s+/, '')
end
end

View File

@@ -0,0 +1,108 @@
class Crm::Leadsquared::Mappers::ConversationMapper
include ::Rails.application.routes.url_helpers
# https://help.leadsquared.com/what-is-the-maximum-character-length-supported-for-lead-and-activity-fields/
# the rest of the body of the note is around 200 chars
# so this limits it
ACTIVITY_NOTE_MAX_SIZE = 1800
def self.map_conversation_activity(hook, conversation)
new(hook, conversation).conversation_activity
end
def self.map_transcript_activity(hook, conversation)
new(hook, conversation).transcript_activity
end
def initialize(hook, conversation)
@hook = hook
@timezone = Time.find_zone(hook.settings['timezone']) || Time.zone
@conversation = conversation
end
def conversation_activity
I18n.t('crm.created_activity',
brand_name: brand_name,
channel_info: conversation.inbox.name,
formatted_creation_time: formatted_creation_time,
display_id: conversation.display_id,
url: conversation_url)
end
def transcript_activity
return I18n.t('crm.no_message') if transcript_messages.empty?
I18n.t('crm.transcript_activity',
brand_name: brand_name,
channel_info: conversation.inbox.name,
display_id: conversation.display_id,
url: conversation_url,
format_messages: format_messages)
end
private
attr_reader :conversation
def formatted_creation_time
conversation.created_at.in_time_zone(@timezone).strftime('%Y-%m-%d %H:%M:%S')
end
def transcript_messages
@transcript_messages ||= conversation.messages.chat.select(&:conversation_transcriptable?)
end
def format_messages
selected_messages = []
separator = "\n\n"
current_length = 0
# Reverse the messages to have latest on top
transcript_messages.reverse_each do |message|
formatted_message = format_message(message)
required_length = formatted_message.length + separator.length # the last one does not need to account for separator, but we add it anyway
break unless (current_length + required_length) <= ACTIVITY_NOTE_MAX_SIZE
selected_messages << formatted_message
current_length += required_length
end
selected_messages.join(separator)
end
def format_message(message)
<<~MESSAGE.strip
[#{message_time(message)}] #{sender_name(message)}: #{message_content(message)}#{attachment_info(message)}
MESSAGE
end
def message_time(message)
message.created_at.in_time_zone(@timezone).strftime('%Y-%m-%d %H:%M')
end
def sender_name(message)
return 'System' if message.sender.nil?
message.sender.name.presence || "#{message.sender_type} #{message.sender_id}"
end
def message_content(message)
message.content.presence || I18n.t('crm.no_content')
end
def attachment_info(message)
return '' unless message.attachments.any?
attachments = message.attachments.map { |a| I18n.t('crm.attachment', type: a.file_type) }.join(', ')
"\n#{attachments}"
end
def conversation_url
app_account_conversation_url(account_id: conversation.account.id, id: conversation.display_id)
end
def brand_name
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'] || 'Chatwoot'
end
end

View File

@@ -0,0 +1,121 @@
class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
def self.crm_name
'leadsquared'
end
def initialize(hook)
super(hook)
@access_key = hook.settings['access_key']
@secret_key = hook.settings['secret_key']
@endpoint_url = hook.settings['endpoint_url']
@allow_transcript = hook.settings['enable_transcript_activity']
@allow_conversation = hook.settings['enable_conversation_activity']
# Initialize API clients
@lead_client = Crm::Leadsquared::Api::LeadClient.new(@access_key, @secret_key, @endpoint_url)
@activity_client = Crm::Leadsquared::Api::ActivityClient.new(@access_key, @secret_key, @endpoint_url)
@lead_finder = Crm::Leadsquared::LeadFinderService.new(@lead_client)
end
def handle_contact(contact)
contact.reload
unless identifiable_contact?(contact)
Rails.logger.info("Contact not identifiable. Skipping handle_contact for ##{contact.id}")
return
end
stored_lead_id = get_external_id(contact)
create_or_update_lead(contact, stored_lead_id)
end
def handle_conversation_created(conversation)
return unless @allow_conversation
create_conversation_activity(
conversation: conversation,
activity_type: 'conversation',
activity_code_key: 'conversation_activity_code',
metadata_key: 'created_activity_id',
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_conversation_activity(@hook, conversation)
)
end
def handle_conversation_resolved(conversation)
return unless @allow_transcript
return unless conversation.status == 'resolved'
create_conversation_activity(
conversation: conversation,
activity_type: 'transcript',
activity_code_key: 'transcript_activity_code',
metadata_key: 'transcript_activity_id',
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_transcript_activity(@hook, conversation)
)
end
private
def create_or_update_lead(contact, lead_id)
lead_data = Crm::Leadsquared::Mappers::ContactMapper.map(contact)
# Why can't we use create_or_update_lead here?
# In LeadSquared, it's possible that the email field
# may not be marked as unique, same with the phone number field
# So we just use the update API if we already have a lead ID
if lead_id.present?
@lead_client.update_lead(lead_data, lead_id)
else
new_lead_id = @lead_client.create_or_update_lead(lead_data)
store_external_id(contact, new_lead_id)
end
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception
Rails.logger.error "LeadSquared API error processing contact: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception
Rails.logger.error "Error processing contact in LeadSquared: #{e.message}"
end
def create_conversation_activity(conversation:, activity_type:, activity_code_key:, metadata_key:, activity_note:)
lead_id = get_lead_id(conversation.contact)
return if lead_id.blank?
activity_code = get_activity_code(activity_code_key)
activity_id = @activity_client.post_activity(lead_id, activity_code, activity_note)
return if activity_id.blank?
metadata = {}
metadata[metadata_key] = activity_id
store_conversation_metadata(conversation, metadata)
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception
Rails.logger.error "LeadSquared API error in #{activity_type} activity: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception
Rails.logger.error "Error creating #{activity_type} activity in LeadSquared: #{e.message}"
end
def get_activity_code(key)
activity_code = @hook.settings[key]
raise StandardError, "LeadSquared #{key} activity code not found for hook ##{@hook.id}." if activity_code.blank?
activity_code
end
def get_lead_id(contact)
contact.reload # reload to ensure all the attributes are up-to-date
unless identifiable_contact?(contact)
Rails.logger.info("Contact not identifiable. Skipping activity for ##{contact.id}")
nil
end
lead_id = @lead_finder.find_or_create(contact)
return nil if lead_id.blank?
store_external_id(contact, lead_id) unless get_external_id(contact)
lead_id
end
end

View File

@@ -0,0 +1,109 @@
class Crm::Leadsquared::SetupService
def initialize(hook)
@hook = hook
credentials = @hook.settings
@access_key = credentials['access_key']
@secret_key = credentials['secret_key']
@client = Crm::Leadsquared::Api::BaseClient.new(@access_key, @secret_key, 'https://api.leadsquared.com/v2/')
@activity_client = Crm::Leadsquared::Api::ActivityClient.new(@access_key, @secret_key, 'https://api.leadsquared.com/v2/')
end
def setup
setup_endpoint
setup_activity
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
ChatwootExceptionTracker.new(e, account: @hook.account).capture_exception
Rails.logger.error "LeadSquared API error in setup: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @hook.account).capture_exception
Rails.logger.error "Error during LeadSquared setup: #{e.message}"
end
def setup_endpoint
response = @client.get('Authentication.svc/UserByAccessKey.Get')
endpoint_host = response['LSQCommonServiceURLs']['api']
app_host = response['LSQCommonServiceURLs']['app']
timezone = response['TimeZone']
endpoint_url = "https://#{endpoint_host}/v2/"
app_url = "https://#{app_host}/"
update_hook_settings({ :endpoint_url => endpoint_url, :app_url => app_url, :timezone => timezone })
# replace the clients
@client = Crm::Leadsquared::Api::BaseClient.new(@access_key, @secret_key, endpoint_url)
@activity_client = Crm::Leadsquared::Api::ActivityClient.new(@access_key, @secret_key, endpoint_url)
end
private
def setup_activity
existing_types = @activity_client.fetch_activity_types
return if existing_types.blank?
activity_codes = setup_activity_types(existing_types)
return if activity_codes.blank?
update_hook_settings(activity_codes)
activity_codes
end
def setup_activity_types(existing_types)
activity_codes = {}
activity_types.each do |activity_type|
activity_id = find_or_create_activity_type(activity_type, existing_types)
if activity_id.present?
activity_codes[activity_type[:setting_key]] = activity_id.to_i
else
Rails.logger.error "Failed to find or create activity type: #{activity_type[:name]}"
end
end
activity_codes
end
def find_or_create_activity_type(activity_type, existing_types)
existing = existing_types.find { |t| t['ActivityEventName'] == activity_type[:name] }
if existing
existing['ActivityEvent'].to_i
else
@activity_client.create_activity_type(
name: activity_type[:name],
score: activity_type[:score],
direction: activity_type[:direction]
)
end
end
def update_hook_settings(params)
@hook.settings = @hook.settings.merge(params)
@hook.save!
end
def activity_types
[
{
name: "#{brand_name} Conversation Started",
score: @hook.settings['conversation_activity_score'].to_i || 0,
direction: 0,
setting_key: 'conversation_activity_code'
},
{
name: "#{brand_name} Conversation Transcript",
score: @hook.settings['transcript_activity_score'].to_i || 0,
direction: 0,
setting_key: 'transcript_activity_code'
}
].freeze
end
def brand_name
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'].presence || 'Chatwoot'
end
end

View File

@@ -0,0 +1,181 @@
class CsatSurveyService
pattr_initialize [:conversation!]
def perform
return unless should_send_csat_survey?
if whatsapp_channel? && template_available_and_approved?
send_whatsapp_template_survey
elsif inbox.twilio_whatsapp? && twilio_template_available_and_approved?
send_twilio_whatsapp_template_survey
elsif within_messaging_window?
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
else
create_csat_not_sent_activity_message
end
end
private
delegate :inbox, :contact, to: :conversation
def should_send_csat_survey?
conversation_allows_csat? && csat_enabled? && !csat_already_sent? && csat_allowed_by_survey_rules?
end
def conversation_allows_csat?
conversation.resolved? && !conversation.tweet?
end
def csat_enabled?
inbox.csat_survey_enabled?
end
def csat_already_sent?
conversation.messages.where(content_type: :input_csat).present?
end
def within_messaging_window?
conversation.can_reply?
end
def csat_allowed_by_survey_rules?
return true unless survey_rules_configured?
labels = conversation.label_list
return true if rule_values.empty?
case rule_operator
when 'contains'
rule_values.any? { |label| labels.include?(label) }
when 'does_not_contain'
rule_values.none? { |label| labels.include?(label) }
else
true
end
end
def survey_rules_configured?
return false if csat_config.blank?
return false if csat_config['survey_rules'].blank?
rule_values.any?
end
def rule_operator
csat_config.dig('survey_rules', 'operator') || 'contains'
end
def rule_values
csat_config.dig('survey_rules', 'values') || []
end
def whatsapp_channel?
inbox.channel_type == 'Channel::Whatsapp'
end
def template_available_and_approved?
template_config = inbox.csat_config&.dig('template')
return false unless template_config
template_name = template_config['name'] || CsatTemplateNameService.csat_template_name(inbox.id)
status_result = inbox.channel.provider_service.get_template_status(template_name)
status_result[:success] && status_result[:template][:status] == 'APPROVED'
rescue StandardError => e
Rails.logger.error "Error checking CSAT template status: #{e.message}"
false
end
def twilio_template_available_and_approved?
template_config = inbox.csat_config&.dig('template')
return false unless template_config
content_sid = template_config['content_sid']
return false unless content_sid
template_service = Twilio::CsatTemplateService.new(inbox.channel)
status_result = template_service.get_template_status(content_sid)
status_result[:success] && status_result[:template][:status] == 'approved'
rescue StandardError => e
Rails.logger.error "Error checking Twilio CSAT template status: #{e.message}"
false
end
def send_whatsapp_template_survey
template_config = inbox.csat_config&.dig('template')
template_name = template_config['name'] || CsatTemplateNameService.csat_template_name(inbox.id)
phone_number = conversation.contact_inbox.source_id
template_info = build_template_info(template_name, template_config)
message = build_csat_message
message_id = inbox.channel.provider_service.send_template(phone_number, template_info, message)
message.update!(source_id: message_id) if message_id.present?
rescue StandardError => e
Rails.logger.error "Error sending WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
end
def build_template_info(template_name, template_config)
{
name: template_name,
lang_code: template_config['language'] || 'en',
parameters: [
{
type: 'button',
sub_type: 'url',
index: '0',
parameters: [{ type: 'text', text: conversation.uuid }]
}
]
}
end
def build_csat_message
conversation.messages.build(
account: conversation.account,
inbox: inbox,
message_type: :outgoing,
content: inbox.csat_config&.dig('message') || 'Please rate this conversation',
content_type: :input_csat
)
end
def csat_config
inbox.csat_config || {}
end
def send_twilio_whatsapp_template_survey
template_config = inbox.csat_config&.dig('template')
content_sid = template_config['content_sid']
phone_number = conversation.contact_inbox.source_id
content_variables = { '1' => conversation.uuid }
message = build_csat_message
send_service = Twilio::SendOnTwilioService.new(message: message)
result = send_service.send_csat_template_message(
phone_number: phone_number,
content_sid: content_sid,
content_variables: content_variables
)
message.update!(source_id: result[:message_id]) if result[:success] && result[:message_id].present?
rescue StandardError => e
Rails.logger.error "Error sending Twilio WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
end
def create_csat_not_sent_activity_message
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
activity_message_params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :activity,
content: content
}
::Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params) if content
end
end

View File

@@ -0,0 +1,196 @@
class CsatTemplateManagementService
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
def initialize(inbox)
@inbox = inbox
end
def template_status
template = @inbox.csat_config&.dig('template')
return { template_exists: false } unless template
if @inbox.twilio_whatsapp?
get_twilio_template_status(template)
else
get_whatsapp_template_status(template)
end
rescue StandardError => e
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
{ service_error: e.message }
end
def create_template(template_params)
validate_template_params!(template_params)
delete_existing_template_if_needed
result = create_template_via_provider(template_params)
update_inbox_csat_config(result) if result[:success]
result
rescue StandardError => e
Rails.logger.error "Error creating CSAT template: #{e.message}"
{ success: false, service_error: 'Template creation failed' }
end
private
def validate_template_params!(template_params)
raise ActionController::ParameterMissing, 'message' if template_params[:message].blank?
end
def create_template_via_provider(template_params)
if @inbox.twilio_whatsapp?
create_twilio_template(template_params)
else
create_whatsapp_template(template_params)
end
end
def create_twilio_template(template_params)
template_config = build_template_config(template_params)
template_service = Twilio::CsatTemplateService.new(@inbox.channel)
template_service.create_template(template_config)
end
def create_whatsapp_template(template_params)
template_config = build_template_config(template_params)
Whatsapp::CsatTemplateService.new(@inbox.channel).create_template(template_config)
end
def build_template_config(template_params)
{
message: template_params[:message],
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
language: template_params[:language] || DEFAULT_LANGUAGE,
template_name: CsatTemplateNameService.csat_template_name(@inbox.id)
}
end
def update_inbox_csat_config(result)
current_config = @inbox.csat_config || {}
template_data = build_template_data_from_result(result)
updated_config = current_config.merge('template' => template_data)
@inbox.update!(csat_config: updated_config)
end
def build_template_data_from_result(result)
if @inbox.twilio_whatsapp?
build_twilio_template_data(result)
else
build_whatsapp_cloud_template_data(result)
end
end
def build_twilio_template_data(result)
{
'friendly_name' => result[:friendly_name],
'content_sid' => result[:content_sid],
'approval_sid' => result[:approval_sid],
'language' => result[:language],
'status' => result[:whatsapp_status] || result[:status],
'created_at' => Time.current.iso8601
}.compact
end
def build_whatsapp_cloud_template_data(result)
{
'name' => result[:template_name],
'template_id' => result[:template_id],
'language' => result[:language],
'created_at' => Time.current.iso8601
}
end
def get_twilio_template_status(template)
content_sid = template['content_sid']
return { template_exists: false } unless content_sid
template_service = Twilio::CsatTemplateService.new(@inbox.channel)
status_result = template_service.get_template_status(content_sid)
if status_result[:success]
{
template_exists: true,
friendly_name: template['friendly_name'],
content_sid: template['content_sid'],
status: status_result[:template][:status],
language: template['language']
}
else
{
template_exists: false,
error: 'Template not found'
}
end
end
def get_whatsapp_template_status(template)
template_name = template['name'] || CsatTemplateNameService.csat_template_name(@inbox.id)
status_result = Whatsapp::CsatTemplateService.new(@inbox.channel).get_template_status(template_name)
if status_result[:success]
{
template_exists: true,
template_name: template_name,
status: status_result[:template][:status],
template_id: status_result[:template][:id]
}
else
{
template_exists: false,
error: 'Template not found'
}
end
end
def delete_existing_template_if_needed
template = @inbox.csat_config&.dig('template')
return true if template.blank?
if @inbox.twilio_whatsapp?
delete_existing_twilio_template(template)
else
delete_existing_whatsapp_template(template)
end
rescue StandardError => e
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
false
end
def delete_existing_twilio_template(template)
content_sid = template['content_sid']
return true if content_sid.blank?
template_service = Twilio::CsatTemplateService.new(@inbox.channel)
deletion_result = template_service.delete_template(nil, content_sid)
if deletion_result[:success]
Rails.logger.info "Deleted existing Twilio CSAT template '#{content_sid}' for inbox #{@inbox.id}"
true
else
Rails.logger.warn "Failed to delete existing Twilio CSAT template '#{content_sid}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
false
end
end
def delete_existing_whatsapp_template(template)
template_name = template['name']
return true if template_name.blank?
csat_template_service = Whatsapp::CsatTemplateService.new(@inbox.channel)
template_status = csat_template_service.get_template_status(template_name)
return true unless template_status[:success]
deletion_result = csat_template_service.delete_template(template_name)
if deletion_result[:success]
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
true
else
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
false
end
end
end

View File

@@ -0,0 +1,49 @@
class CsatTemplateNameService
CSAT_BASE_NAME = 'customer_satisfaction_survey'.freeze
# Generates template names like: customer_satisfaction_survey_{inbox_id}_{version_number}
def self.csat_template_name(inbox_id, version = nil)
base_name = csat_base_name_for_inbox(inbox_id)
version ? "#{base_name}_#{version}" : base_name
end
def self.extract_version(template_name, inbox_id)
return nil if template_name.blank?
pattern = versioned_pattern_for_inbox(inbox_id)
match = template_name.match(pattern)
match ? match[1].to_i : nil
end
def self.generate_next_template_name(base_name, inbox_id, current_template_name)
return base_name if current_template_name.blank?
current_version = extract_version(current_template_name, inbox_id)
next_version = current_version ? current_version + 1 : 1
csat_template_name(inbox_id, next_version)
end
def self.matches_csat_pattern?(template_name, inbox_id)
return false if template_name.blank?
base_pattern = base_pattern_for_inbox(inbox_id)
versioned_pattern = versioned_pattern_for_inbox(inbox_id)
template_name.match?(base_pattern) || template_name.match?(versioned_pattern)
end
def self.csat_base_name_for_inbox(inbox_id)
"#{CSAT_BASE_NAME}_#{inbox_id}"
end
def self.base_pattern_for_inbox(inbox_id)
/^#{CSAT_BASE_NAME}_#{inbox_id}$/
end
def self.versioned_pattern_for_inbox(inbox_id)
/^#{CSAT_BASE_NAME}_#{inbox_id}_(\d+)$/
end
private_class_method :csat_base_name_for_inbox, :base_pattern_for_inbox, :versioned_pattern_for_inbox
end

View File

@@ -0,0 +1,68 @@
class DataImport::ContactManager
def initialize(account)
@account = account
end
def build_contact(params)
contact = find_or_initialize_contact(params)
update_contact_attributes(params, contact)
contact
end
def find_or_initialize_contact(params)
contact = find_existing_contact(params)
contact_params = params.slice(:email, :identifier, :phone_number)
contact_params[:phone_number] = format_phone_number(contact_params[:phone_number]) if contact_params[:phone_number].present?
contact ||= @account.contacts.new(contact_params)
contact
end
def find_existing_contact(params)
contact = find_contact_by_identifier(params)
contact ||= find_contact_by_email(params)
contact ||= find_contact_by_phone_number(params)
update_contact_with_merged_attributes(params, contact) if contact.present? && contact.valid?
contact
end
def find_contact_by_identifier(params)
return unless params[:identifier]
@account.contacts.find_by(identifier: params[:identifier])
end
def find_contact_by_email(params)
return unless params[:email]
@account.contacts.from_email(params[:email])
end
def find_contact_by_phone_number(params)
return unless params[:phone_number]
@account.contacts.find_by(phone_number: format_phone_number(params[:phone_number]))
end
def format_phone_number(phone_number)
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
end
def update_contact_with_merged_attributes(params, contact)
contact.identifier = params[:identifier] if params[:identifier].present?
contact.email = params[:email] if params[:email].present?
contact.phone_number = format_phone_number(params[:phone_number]) if params[:phone_number].present?
update_contact_attributes(params, contact)
contact.save
end
private
def update_contact_attributes(params, contact)
contact.name = params[:name] if params[:name].present?
contact.additional_attributes ||= {}
contact.additional_attributes[:company] = params[:company] if params[:company].present?
contact.additional_attributes[:city] = params[:city] if params[:city].present?
contact.assign_attributes(custom_attributes: contact.custom_attributes.merge(params.except(:identifier, :email, :name, :phone_number)))
end
end

View File

@@ -0,0 +1,18 @@
class Email::SendOnEmailService < Base::SendOnChannelService
private
def channel_class
Channel::Email
end
def perform_reply
return unless message.email_notifiable_message?
reply_mail = ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now
Rails.logger.info("Email message #{message.id} sent with source_id: #{reply_mail.message_id}")
message.update(source_id: reply_mail.message_id)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
end
end

View File

@@ -0,0 +1,87 @@
# Code is heavily inspired by panaromic gem
# https://github.com/andreapavoni/panoramic
# We will try to find layouts and content from database
# layout will be rendered with erb and other content in html format
# Further processing in liquid is implemented in mailers
# NOTE: rails resolver looks for templates in cache first
# which we don't want to happen here
# so we are overriding find_all method in action view resolver
# If anything breaks - look into rails : actionview/lib/action_view/template/resolver.rb
class ::EmailTemplates::DbResolverService < ActionView::Resolver
require 'singleton'
include Singleton
# Instantiate Resolver by passing a model.
def self.using(model, options = {})
class_variable_set(:@@model, model)
class_variable_set(:@@resolver_options, options)
instance
end
# Since rails picks up files from cache. lets override the method
# Normalizes the arguments and passes it on to find_templates.
# rubocop:disable Metrics/ParameterLists
def find_all(name, prefix = nil, partial = false, details = {}, key = nil, locals = [])
locals = locals.map(&:to_s).sort!.freeze
_find_all(name, prefix, partial, details, key, locals)
end
# rubocop:enable Metrics/ParameterLists
# the function has to accept(name, prefix, partial, _details, _locals = [])
# _details contain local info which we can leverage in future
# cause of codeclimate issue with 4 args, relying on (*args)
def find_templates(name, prefix, partial, *_args)
@template_name = name
@template_type = prefix.include?('layout') ? 'layout' : 'content'
@db_template = find_db_template
return [] if @db_template.blank?
path = build_path(prefix)
handler = ActionView::Template.registered_template_handler(:liquid)
template_details = {
locals: [],
format: Mime['html'].to_sym,
virtual_path: virtual_path(path, partial)
}
[ActionView::Template.new(@db_template.body, "DB Template - #{@db_template.id}", handler, **template_details)]
end
private
def find_db_template
find_account_template || find_installation_template
end
def find_account_template
return unless Current.account
@@model.find_by(name: @template_name, template_type: @template_type, account: Current.account)
end
def find_installation_template
@@model.find_by(name: @template_name, template_type: @template_type, account: nil)
end
# Build path with eventual prefix
def build_path(prefix)
prefix.present? ? "#{prefix}/#{@template_name}" : @template_name
end
# returns a path depending if its a partial or template
# params path: path/to/file.ext partial: true/false
# the function appends _to make the file name _file.ext if partial: true
def virtual_path(path, partial)
return path unless partial
if (index = path.rindex('/'))
path.insert(index + 1, '_')
else
"_#{path}"
end
end
end

View File

@@ -0,0 +1,114 @@
class Facebook::SendOnFacebookService < Base::SendOnChannelService
private
def channel_class
Channel::FacebookPage
end
def perform_reply
send_message_to_facebook fb_text_message_params if message.content.present?
if message.attachments.present?
message.attachments.each do |attachment|
send_message_to_facebook fb_attachment_message_params(attachment)
end
end
rescue Facebook::Messenger::FacebookError => e
# TODO : handle specific errors or else page will get disconnected
handle_facebook_error(e)
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
end
def send_message_to_facebook(delivery_params)
parsed_result = deliver_message(delivery_params)
return if parsed_result.nil?
if parsed_result['error'].present?
Messages::StatusUpdateService.new(message, 'failed', external_error(parsed_result)).perform
Rails.logger.info "Facebook::SendOnFacebookService: Error sending message to Facebook : Page - #{channel.page_id} : #{parsed_result}"
end
message.update!(source_id: parsed_result['message_id']) if parsed_result['message_id'].present?
end
def deliver_message(delivery_params)
result = Facebook::Messenger::Bot.deliver(delivery_params, page_id: channel.page_id)
JSON.parse(result)
rescue JSON::ParserError
Messages::StatusUpdateService.new(message, 'failed', 'Facebook was unable to process this request').perform
Rails.logger.error "Facebook::SendOnFacebookService: Error parsing JSON response from Facebook : Page - #{channel.page_id} : #{result}"
nil
rescue Net::OpenTimeout
Messages::StatusUpdateService.new(message, 'failed', 'Request timed out, please try again later').perform
Rails.logger.error "Facebook::SendOnFacebookService: Timeout error sending message to Facebook : Page - #{channel.page_id}"
nil
end
def fb_text_message_params
{
recipient: { id: contact.get_source_id(inbox.id) },
message: fb_text_message_payload,
messaging_type: 'MESSAGE_TAG',
tag: 'ACCOUNT_UPDATE'
}
end
def fb_text_message_payload
if message.content_type == 'input_select' && message.content_attributes['items'].any?
{
text: message.content,
quick_replies: message.content_attributes['items'].map do |item|
{
content_type: 'text',
payload: item['title'],
title: item['title']
}
end
}
else
{ text: message.outgoing_content }
end
end
def external_error(response)
# https://developers.facebook.com/docs/graph-api/guides/error-handling/
error_message = response['error']['message']
error_code = response['error']['code']
"#{error_code} - #{error_message}"
end
def fb_attachment_message_params(attachment)
{
recipient: { id: contact.get_source_id(inbox.id) },
message: {
attachment: {
type: attachment_type(attachment),
payload: {
url: attachment.download_url
}
}
},
messaging_type: 'MESSAGE_TAG',
tag: 'ACCOUNT_UPDATE'
}
end
def attachment_type(attachment)
return attachment.file_type if %w[image audio video file].include? attachment.file_type
'file'
end
def sent_first_outgoing_message_after_24_hours?
# we can send max 1 message after 24 hour window
conversation.messages.outgoing.where('id > ?', conversation.last_incoming_message.id).count == 1
end
def handle_facebook_error(exception)
# Refer: https://github.com/jgorset/facebook-messenger/blob/64fe1f5cef4c1e3fca295b205037f64dfebdbcab/lib/facebook/messenger/error.rb
return unless exception.to_s.include?('The session has been invalidated') || exception.to_s.include?('Error validating access token')
channel.authorization_error!
end
end

View File

@@ -0,0 +1,210 @@
require 'json'
class FilterService
include Filters::FilterHelper
include CustomExceptions::CustomFilter
ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
ATTRIBUTE_TYPES = {
date: 'date', text: 'text', number: 'numeric', link: 'text', list: 'text', checkbox: 'boolean'
}.with_indifferent_access
def initialize(params, user)
@params = params
@user = user
file = File.read('./lib/filters/filter_keys.yml')
@filters = YAML.safe_load(file)
@query_string = ''
@filter_values = {}
end
def perform; end
def filter_operation(query_hash, current_index)
case query_hash[:filter_operator]
when 'equal_to', 'not_equal_to'
@filter_values["value_#{current_index}"] = filter_values(query_hash)
equals_to_filter_string(query_hash[:filter_operator], current_index)
when 'contains', 'does_not_contain'
@filter_values["value_#{current_index}"] = values_for_ilike(query_hash)
ilike_filter_string(query_hash[:filter_operator], current_index)
when 'is_present'
@filter_values["value_#{current_index}"] = 'IS NOT NULL'
when 'is_not_present'
@filter_values["value_#{current_index}"] = 'IS NULL'
when 'is_greater_than', 'is_less_than'
@filter_values["value_#{current_index}"] = lt_gt_filter_values(query_hash)
when 'days_before'
@filter_values["value_#{current_index}"] = days_before_filter_values(query_hash)
else
@filter_values["value_#{current_index}"] = filter_values(query_hash).to_s
"= :value_#{current_index}"
end
end
def filter_values(query_hash)
attribute_key = query_hash['attribute_key']
values = query_hash['values']
return conversation_status_values(values) if attribute_key == 'status'
return conversation_priority_values(values) if attribute_key == 'priority'
return message_type_values(values) if attribute_key == 'message_type'
return downcase_array_values(values) if attribute_key == 'content'
case_insensitive_values(query_hash)
end
def downcase_array_values(values)
values.map(&:downcase)
end
def case_insensitive_values(query_hash)
if @custom_attribute_type.present? && query_hash['values'][0].is_a?(String)
string_filter_values(query_hash)
else
query_hash['values']
end
end
def values_for_ilike(query_hash)
if query_hash['values'].is_a?(Array)
query_hash['values']
.map { |item| "%#{item.strip}%" }
else
["%#{query_hash['values'].strip}%"]
end
end
def string_filter_values(query_hash)
return query_hash['values'][0].downcase if query_hash['values'].is_a?(Array)
query_hash['values'].downcase
end
def lt_gt_filter_values(query_hash)
attribute_key = query_hash[:attribute_key]
attribute_model = query_hash['custom_attribute_type'].presence || self.class::ATTRIBUTE_MODEL
attribute_type = custom_attribute(attribute_key, @account, attribute_model).try(:attribute_display_type)
attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
value = query_hash['values'][0]
operator = query_hash['filter_operator'] == 'is_less_than' ? '<' : '>'
"#{operator} '#{value}'::#{attribute_data_type}"
end
def days_before_filter_values(query_hash)
date = Time.zone.today - query_hash['values'][0].to_i.days
query_hash['values'] = [date.strftime]
query_hash['filter_operator'] = 'is_less_than'
lt_gt_filter_values(query_hash)
end
def set_count_for_all_conversations
[
@conversations.assigned_to(@user).count,
@conversations.unassigned.count,
@conversations.count
]
end
def tag_filter_query(query_hash, current_index)
model_name = filter_config[:entity]
table_name = filter_config[:table_name]
query_operator = query_hash[:query_operator]
@filter_values["value_#{current_index}"] = filter_values(query_hash)
tag_model_relation_query =
"SELECT * FROM taggings WHERE taggings.taggable_id = #{table_name}.id AND taggings.taggable_type = '#{model_name}'"
tag_query =
"AND taggings.tag_id IN (SELECT tags.id FROM tags WHERE tags.name IN (:value_#{current_index}))"
case query_hash[:filter_operator]
when 'equal_to'
"EXISTS (#{tag_model_relation_query} #{tag_query}) #{query_operator}"
when 'not_equal_to'
"NOT EXISTS (#{tag_model_relation_query} #{tag_query}) #{query_operator}"
when 'is_present'
"EXISTS (#{tag_model_relation_query}) #{query_operator}"
when 'is_not_present'
"NOT EXISTS (#{tag_model_relation_query}) #{query_operator}"
end
end
def custom_attribute_query(query_hash, custom_attribute_type, current_index)
@attribute_key = query_hash[:attribute_key]
@custom_attribute_type = custom_attribute_type
attribute_data_type
return '' if @custom_attribute.blank?
build_custom_attr_query(query_hash, current_index)
end
private
def attribute_model
@attribute_model = @custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL
end
def attribute_data_type
attribute_type = custom_attribute(@attribute_key, @account, attribute_model).try(:attribute_display_type)
@attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
end
def build_custom_attr_query(query_hash, current_index)
filter_operator_value = filter_operation(query_hash, current_index)
query_operator = query_hash[:query_operator]
table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts'
query = if attribute_data_type == 'text'
"LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
else
"(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
end
query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
end
def custom_attribute(attribute_key, account, custom_attribute_type)
current_account = account || Current.account
attribute_model = custom_attribute_type.presence || self.class::ATTRIBUTE_MODEL
@custom_attribute = current_account.custom_attribute_definitions.where(
attribute_model: attribute_model
).find_by(attribute_key: attribute_key)
end
def not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
return '' unless query_hash[:filter_operator] == 'not_equal_to'
" OR (#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} IS NULL "
end
def equals_to_filter_string(filter_operator, current_index)
return "IN (:value_#{current_index})" if filter_operator == 'equal_to'
"NOT IN (:value_#{current_index})"
end
def ilike_filter_string(filter_operator, current_index)
return "ILIKE ANY (ARRAY[:value_#{current_index}])" if %w[contains].include?(filter_operator)
"NOT ILIKE ALL (ARRAY[:value_#{current_index}])"
end
def like_filter_string(filter_operator, current_index)
return "LIKE :value_#{current_index}" if %w[contains starts_with].include?(filter_operator)
"NOT LIKE :value_#{current_index}"
end
def query_builder(model_filters)
@params[:payload].each_with_index do |query_hash, current_index|
@query_string += " #{build_condition_query(model_filters, query_hash, current_index).strip}"
end
base_relation.where(@query_string, @filter_values.with_indifferent_access)
end
def validate_query_operator
@params[:payload].each do |query_hash|
validate_single_condition(query_hash)
end
end
end

View File

@@ -0,0 +1,49 @@
require 'rubygems/package'
class Geocoder::SetupService
def perform
return if File.exist?(GeocoderConfiguration::LOOK_UP_DB)
ip_lookup_api_key = ENV.fetch('IP_LOOKUP_API_KEY', nil)
if ip_lookup_api_key.blank?
log_info('IP_LOOKUP_API_KEY empty. Skipping geoip database setup')
return
end
log_info('Fetch GeoLite2-City database')
fetch_and_extract_database(ip_lookup_api_key)
end
private
def fetch_and_extract_database(api_key)
base_url = ENV.fetch('IP_LOOKUP_BASE_URL', 'https://download.maxmind.com/app/geoip_download')
source_file = Down.download("#{base_url}?edition_id=GeoLite2-City&suffix=tar.gz&license_key=#{api_key}")
extract_tar_file(source_file)
log_info('Fetch complete')
rescue StandardError => e
log_error(e.message)
end
def extract_tar_file(source_file)
tar_extract = Gem::Package::TarReader.new(Zlib::GzipReader.open(source_file))
tar_extract.rewind
tar_extract.each do |entry|
next unless entry.full_name.include?('GeoLite2-City.mmdb') && entry.file?
File.open GeocoderConfiguration::LOOK_UP_DB, 'wb' do |f|
f.print entry.read
end
end
end
def log_info(message)
Rails.logger.info "[rake ip_lookup:setup] #{message}"
end
def log_error(message)
Rails.logger.error "[rake ip_lookup:setup] #{message}"
end
end

View File

@@ -0,0 +1,12 @@
# Refer: https://learn.microsoft.com/en-us/entra/identity-platform/configurable-token-lifetimes
class Google::RefreshOauthTokenService < BaseRefreshOauthTokenService
private
# Builds the OAuth strategy for Microsoft Graph
def build_oauth_strategy
app_id = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_SECRET', nil)
OmniAuth::Strategies::GoogleOauth2.new(nil, app_id, app_secret)
end
end

View File

@@ -0,0 +1,130 @@
require 'net/imap'
class Imap::BaseFetchEmailService
pattr_initialize [:channel!, :interval]
def fetch_emails
# Override this method
end
def perform
inbound_emails = fetch_emails
terminate_imap_connection
inbound_emails
end
private
def authentication_type
# Override this method
end
def imap_password
# Override this method
end
def imap_client
@imap_client ||= build_imap_client
end
def mail_info_logger(inbound_mail, seq_no)
return if Rails.env.test?
Rails.logger.info("
#{channel.provider} Email id: #{inbound_mail.from} - message_source_id: #{inbound_mail.message_id} - sequence id: #{seq_no}")
end
def email_already_present?(channel, message_id)
channel.inbox.messages.find_by(source_id: message_id).present?
end
def fetch_mail_for_channel
message_ids_with_seq = fetch_message_ids_with_sequence
message_ids_with_seq.filter_map do |message_id_with_seq|
process_message_id(message_id_with_seq)
end
end
def process_message_id(message_id_with_seq)
seq_no, message_id = message_id_with_seq
if message_id.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Empty message id for #{channel.email} with seq no. <#{seq_no}>."
return
end
return if email_already_present?(channel, message_id)
# Fetch the original mail content using the sequence no
mail_str = imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
if mail_str.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetch failed for #{channel.email} with message-id <#{message_id}>."
return
end
inbound_mail = build_mail_from_string(mail_str)
mail_info_logger(inbound_mail, seq_no)
inbound_mail
end
# Sends a FETCH command to retrieve data associated with a message in the mailbox.
# You can send batches of message sequence number in `.fetch` method.
def fetch_message_ids_with_sequence
seq_nums = fetch_available_mail_sequence_numbers
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{channel.email}, found #{seq_nums.length}."
message_ids_with_seq = []
seq_nums.each_slice(10).each do |batch|
# Fetch only message-id only without mail body or contents.
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
# .fetch returns an array of Net::IMAP::FetchData or nil
# (instead of an empty array) if there is no matching message.
# Check
if batch_message_ids.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
next
end
batch_message_ids.each do |data|
message_id = build_mail_from_string(data.attr['BODY[HEADER]']).message_id
message_ids_with_seq.push([data.seqno, message_id])
end
end
message_ids_with_seq
end
# Sends a SEARCH command to search the mailbox for messages that were
# created between yesterday (or given date) and today and returns message sequence numbers.
# Return <message set>
def fetch_available_mail_sequence_numbers
imap_client.search(['SINCE', since])
end
def build_imap_client
imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: true)
imap.authenticate(authentication_type, channel.imap_login, imap_password)
imap.select('INBOX')
imap
end
def terminate_imap_connection
imap_client.logout
rescue Net::IMAP::Error => e
Rails.logger.info "Logout failed for #{channel.email} - #{e.message}."
imap_client.disconnect
end
def build_mail_from_string(raw_email_content)
Mail.read_from_string(raw_email_content)
end
def since
previous_day = Time.zone.today - (interval || 1).to_i
previous_day.strftime('%d-%b-%Y')
end
end

View File

@@ -0,0 +1,15 @@
class Imap::FetchEmailService < Imap::BaseFetchEmailService
def fetch_emails
fetch_mail_for_channel
end
private
def authentication_type
'PLAIN'
end
def imap_password
channel.imap_password
end
end

View File

@@ -0,0 +1,17 @@
class Imap::GoogleFetchEmailService < Imap::BaseFetchEmailService
def fetch_emails
return if channel.provider_config['access_token'].blank?
fetch_mail_for_channel
end
private
def authentication_type
'XOAUTH2'
end
def imap_password
Google::RefreshOauthTokenService.new(channel: channel).access_token
end
end

View File

@@ -0,0 +1,17 @@
class Imap::MicrosoftFetchEmailService < Imap::BaseFetchEmailService
def fetch_emails
return if channel.provider_config['access_token'].blank?
fetch_mail_for_channel
end
private
def authentication_type
'XOAUTH2'
end
def imap_password
Microsoft::RefreshOauthTokenService.new(channel: channel).access_token
end
end

View File

@@ -0,0 +1,69 @@
class Instagram::BaseMessageText < Instagram::WebhooksBaseService
attr_reader :messaging
def initialize(messaging, channel)
@messaging = messaging
super(channel)
end
def perform
connected_instagram_id, contact_id = instagram_and_contact_ids
inbox_channel(connected_instagram_id)
return if @inbox.blank?
if @inbox.channel.reauthorization_required?
Rails.logger.info("Skipping message processing as reauthorization is required for inbox #{@inbox.id}")
return
end
return unsend_message if message_is_deleted?
ensure_contact(contact_id) if contacts_first_message?(contact_id)
create_message
end
private
def instagram_and_contact_ids
if agent_message_via_echo?
[@messaging[:sender][:id], @messaging[:recipient][:id]]
else
[@messaging[:recipient][:id], @messaging[:sender][:id]]
end
end
def agent_message_via_echo?
@messaging[:message][:is_echo].present?
end
def message_is_deleted?
@messaging[:message][:is_deleted].present?
end
# if contact was present before find out contact_inbox to create message
def contacts_first_message?(ig_scope_id)
@contact_inbox = @inbox.contact_inboxes.where(source_id: ig_scope_id).last
@contact_inbox.blank? && @inbox.channel.instagram_id.present?
end
def unsend_message
message_to_delete = @inbox.messages.find_by(
source_id: @messaging[:message][:mid]
)
return if message_to_delete.blank?
message_to_delete.attachments.destroy_all
message_to_delete.update!(content: I18n.t('conversations.messages.deleted'), deleted: true)
end
# Methods to be implemented by subclasses
def ensure_contact(contact_id)
raise NotImplementedError, "#{self.class} must implement #ensure_contact"
end
def create_message
raise NotImplementedError, "#{self.class} must implement #create_message"
end
end

View File

@@ -0,0 +1,95 @@
class Instagram::BaseSendService < Base::SendOnChannelService
pattr_initialize [:message!]
private
delegate :additional_attributes, to: :contact
def perform_reply
send_attachments if message.attachments.present?
send_content if message.content.present?
rescue StandardError => e
handle_error(e)
end
def send_attachments
message.attachments.each do |attachment|
send_message(attachment_message_params(attachment))
end
end
def send_content
send_message(message_params)
end
def handle_error(error)
ChatwootExceptionTracker.new(error, account: message.account, user: message.sender).capture_exception
end
def message_params
params = {
recipient: { id: contact.get_source_id(inbox.id) },
message: {
text: message.outgoing_content
}
}
merge_human_agent_tag(params)
end
def attachment_message_params(attachment)
params = {
recipient: { id: contact.get_source_id(inbox.id) },
message: {
attachment: {
type: attachment_type(attachment),
payload: {
url: attachment.download_url
}
}
}
}
merge_human_agent_tag(params)
end
def process_response(response, message_content)
parsed_response = response.parsed_response
if response.success? && parsed_response['error'].blank?
message.update!(source_id: parsed_response['message_id'])
parsed_response
else
external_error = external_error(parsed_response)
Rails.logger.error("Instagram response: #{external_error} : #{message_content}")
Messages::StatusUpdateService.new(message, 'failed', external_error).perform
nil
end
end
def external_error(response)
error_message = response.dig('error', 'message')
error_code = response.dig('error', 'code')
# https://developers.facebook.com/docs/messenger-platform/error-codes
# Access token has expired or become invalid. This may be due to a password change,
# removal of the connected app from Instagram account settings, or other reasons.
channel.authorization_error! if error_code == 190
"#{error_code} - #{error_message}"
end
def attachment_type(attachment)
return attachment.file_type if %w[image audio video file].include? attachment.file_type
'file'
end
# Methods to be implemented by child classes
def send_message(message_content)
raise NotImplementedError, 'Subclasses must implement send_message'
end
def merge_human_agent_tag(params)
raise NotImplementedError, 'Subclasses must implement merge_human_agent_tag'
end
end

View File

@@ -0,0 +1,94 @@
class Instagram::MessageText < Instagram::BaseMessageText
attr_reader :messaging
def ensure_contact(ig_scope_id)
result = fetch_instagram_user(ig_scope_id)
find_or_create_contact(result) if result.present?
end
def fetch_instagram_user(ig_scope_id)
fields = 'name,username,profile_pic,follower_count,is_user_follow_business,is_business_follow_user,is_verified_user'
url = "#{base_uri}/#{ig_scope_id}?fields=#{fields}&access_token=#{@inbox.channel.access_token}"
response = HTTParty.get(url)
return process_successful_response(response) if response.success?
handle_error_response(response, ig_scope_id) || {}
end
private
def process_successful_response(response)
result = JSON.parse(response.body).with_indifferent_access
{
'name' => result['name'],
'username' => result['username'],
'profile_pic' => result['profile_pic'],
'id' => result['id'],
'follower_count' => result['follower_count'],
'is_user_follow_business' => result['is_user_follow_business'],
'is_business_follow_user' => result['is_business_follow_user'],
'is_verified_user' => result['is_verified_user']
}.with_indifferent_access
end
def handle_error_response(response, ig_scope_id)
parsed_response = response.parsed_response
parsed_response = JSON.parse(parsed_response) if parsed_response.is_a?(String)
error_message = parsed_response.dig('error', 'message')
error_code = parsed_response.dig('error', 'code')
# https://developers.facebook.com/docs/messenger-platform/error-codes
# Access token has expired or become invalid.
channel.authorization_error! if error_code == 190
# TODO: Remove this once we have a better way to handle this error.
# https://developers.facebook.com/docs/messenger-platform/instagram/features/user-profile/#user-consent
# The error typically occurs when the connected Instagram account attempts to send a message to a user
# who has never messaged this Instagram account before.
# We can only get consent to access a user's profile if they have previously sent a message to the connected Instagram account.
# In such cases, we receive the error "User consent is required to access user profile".
# We can safely ignore this error.
return if error_code == 230
# The error occurs when Facebook tries to validate the Facebook App created to authorize Instagram integration.
# The Facebook's agent uses a Bot to make tests on the App where is not a valid user via API,
# returning `{"error"=>{"message"=>"No matching Instagram user", "type"=>"IGApiException", "code"=>9010}}`.
# Then Facebook rejects the request saying this app is still not ready once the integration with Instagram didn't work.
# We can safely create an unknown contact, making this integration work.
return unknown_user(ig_scope_id) if error_code == 9010
# Handle error code 100: Object doesn't exist or missing permissions
# This typically occurs when trying to fetch a user that doesn't exist or has privacy restrictions
# We can safely create an unknown contact, similar to error 9010
return unknown_user(ig_scope_id) if error_code == 100
Rails.logger.warn("[InstagramUserFetchError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id} ig_scope_id #{ig_scope_id}")
Rails.logger.warn("[InstagramUserFetchError]: #{error_message} #{error_code}")
exception = StandardError.new("#{error_message} (Code: #{error_code}, IG Scope ID: #{ig_scope_id})")
ChatwootExceptionTracker.new(exception, account: @inbox.account).capture_exception
# Explicitly return empty hash for any unhandled error codes
# This prevents the exception tracker result from being returned
{}
end
def base_uri
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
end
def create_message
return unless @contact_inbox
Messages::Instagram::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform
end
def unknown_user(ig_scope_id)
{
'name' => "Unknown (IG: #{ig_scope_id})",
'id' => ig_scope_id
}.with_indifferent_access
end
end

View File

@@ -0,0 +1,56 @@
class Instagram::Messenger::MessageText < Instagram::BaseMessageText
private
def ensure_contact(ig_scope_id)
result = fetch_instagram_user(ig_scope_id)
find_or_create_contact(result) if result.present?
end
def fetch_instagram_user(ig_scope_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(ig_scope_id) || {}
rescue Koala::Facebook::AuthenticationError => e
handle_authentication_error(e)
{}
rescue StandardError, Koala::Facebook::ClientError => e
handle_client_error(e)
{}
end
def handle_authentication_error(error)
@inbox.channel.authorization_error!
Rails.logger.warn("Authorization error for account #{@inbox.account_id} for inbox #{@inbox.id}")
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
end
def handle_client_error(error)
# Handle error code 230: User consent is required to access user profile
# This typically occurs when the connected Instagram account attempts to send a message to a user
# who has never messaged this Instagram account before.
# We can safely ignore this error as per Facebook documentation.
if error.message.include?('230')
Rails.logger.warn error
return
end
# Handle error code 9010: No matching Instagram user
# This occurs when trying to fetch an Instagram user that doesn't exist or is not accessible
# We can safely ignore this error and return empty result
if error.message.include?('9010')
Rails.logger.warn("[Instagram User Not Found]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
Rails.logger.warn("[Instagram User Not Found]: #{error.message}")
Rails.logger.warn("[Instagram User Not Found]: #{error}")
return
end
Rails.logger.warn("[FacebookUserFetchClientError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id}")
Rails.logger.warn("[FacebookUserFetchClientError]: #{error.message}")
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
end
def create_message
return unless @contact_inbox
Messages::Instagram::Messenger::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform
end
end

View File

@@ -0,0 +1,40 @@
class Instagram::Messenger::SendOnInstagramService < Instagram::BaseSendService
private
def channel_class
Channel::FacebookPage
end
# Deliver a message with the given payload.
# @see https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message
def send_message(message_content)
access_token = channel.page_access_token
app_secret_proof = calculate_app_secret_proof(GlobalConfigService.load('FB_APP_SECRET', ''), access_token)
query = { access_token: access_token }
query[:appsecret_proof] = app_secret_proof if app_secret_proof
response = HTTParty.post(
'https://graph.facebook.com/v11.0/me/messages',
body: message_content,
query: query
)
process_response(response, message_content)
end
def calculate_app_secret_proof(app_secret, access_token)
Facebook::Messenger::Configuration::AppSecretProofCalculator.call(
app_secret, access_token
)
end
def merge_human_agent_tag(params)
global_config = GlobalConfig.get('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT')
return params unless global_config['ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT']
params[:messaging_type] = 'MESSAGE_TAG'
params[:tag] = 'HUMAN_AGENT'
params
end
end

View File

@@ -0,0 +1,19 @@
class Instagram::ReadStatusService
pattr_initialize [:params!, :channel!]
def perform
return if channel.blank?
::Conversations::UpdateMessageStatusJob.perform_later(message.conversation.id, message.created_at) if message.present?
end
def instagram_id
params[:recipient][:id]
end
def message
return unless params[:read][:mid]
@message ||= @channel.inbox.messages.find_by(source_id: params[:read][:mid])
end
end

View File

@@ -0,0 +1,84 @@
# Service to handle Instagram access token refresh logic
# Instagram tokens are valid for 60 days and can be refreshed to extend validity
# This service implements the refresh logic per official Instagram API guidelines
class Instagram::RefreshOauthTokenService
attr_reader :channel
def initialize(channel:)
@channel = channel
end
# Returns a valid access token, refreshing it if necessary and eligible
def access_token
return unless token_valid?
# If token is valid and eligible for refresh, attempt to refresh it
return channel[:access_token] unless token_eligible_for_refresh?
attempt_token_refresh
end
private
# Checks if the current token is still valid (not expired)
def token_valid?
return false if channel.expires_at.blank?
# Check if token is still valid
Time.current < channel.expires_at
end
# Determines if a token is eligible for refresh based on Instagram's requirements
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#refresh-a-long-lived-token
def token_eligible_for_refresh?
# Three conditions must be met:
# 1. Token is still valid
token_is_valid = Time.current < channel.expires_at
# 2. Token is at least 24 hours old (based on updated_at)
token_is_old_enough = channel.updated_at.present? && Time.current - channel.updated_at >= 24.hours
# 3. Token is approaching expiry (within 10 days)
approaching_expiry = channel.expires_at < 10.days.from_now
token_is_valid && token_is_old_enough && approaching_expiry
end
# Makes an API request to refresh the long-lived token
# @return [Hash] Response data containing new access_token and expires_in values
# @raise [RuntimeError] If API request fails
def refresh_long_lived_token
endpoint = 'https://graph.instagram.com/refresh_access_token'
params = {
grant_type: 'ig_refresh_token',
access_token: channel[:access_token]
}
response = HTTParty.get(endpoint, query: params, headers: { 'Accept' => 'application/json' })
unless response.success?
Rails.logger.error "Failed to refresh Instagram token: #{response.body}"
raise "Failed to refresh Instagram token: #{response.body}"
end
JSON.parse(response.body)
end
def update_channel_tokens(token_data)
channel.update!(
access_token: token_data['access_token'],
expires_at: Time.current + token_data['expires_in'].seconds
)
end
# Attempts to refresh the token, returning either the new or existing token
def attempt_token_refresh
refreshed_token_data = refresh_long_lived_token
update_channel_tokens(refreshed_token_data)
channel.reload[:access_token]
rescue StandardError => e
Rails.logger.error("Token refresh failed: #{e.message}")
channel[:access_token]
end
end

View File

@@ -0,0 +1,33 @@
class Instagram::SendOnInstagramService < Instagram::BaseSendService
private
def channel_class
Channel::Instagram
end
# Deliver a message with the given payload.
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api
def send_message(message_content)
access_token = channel.access_token
query = { access_token: access_token }
instagram_id = channel.instagram_id.presence || 'me'
response = HTTParty.post(
"https://graph.instagram.com/v22.0/#{instagram_id}/messages",
body: message_content,
query: query
)
process_response(response, message_content)
end
def merge_human_agent_tag(params)
global_config = GlobalConfig.get('ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT')
return params unless global_config['ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT']
params[:messaging_type] = 'MESSAGE_TAG'
params[:tag] = 'HUMAN_AGENT'
params
end
end

View File

@@ -0,0 +1,79 @@
class Instagram::TestEventService
def initialize(messaging)
@messaging = messaging
end
def perform
Rails.logger.info("Processing Instagram test webhook event, #{@messaging}")
return false unless test_webhook_event?
create_test_text
end
private
def test_webhook_event?
@messaging[:sender][:id] == '12334' && @messaging[:recipient][:id] == '23245'
end
def create_test_text
# As of now, we are using the last created instagram channel as the test channel,
# since we don't have any other channel for testing purpose at the time of meta approval
channel = Channel::Instagram.last
@inbox = ::Inbox.find_by(channel: channel)
return unless @inbox
@contact = create_test_contact
@conversation ||= create_test_conversation(conversation_params)
@message = @conversation.messages.create!(test_message_params)
end
def create_test_contact
@contact_inbox = @inbox.contact_inboxes.where(source_id: @messaging[:sender][:id]).first
unless @contact_inbox
@contact_inbox ||= @inbox.channel.create_contact_inbox(
'sender_username', 'sender_username'
)
end
@contact_inbox.contact
end
def create_test_conversation(conversation_params)
Conversation.find_by(conversation_params) || build_conversation(conversation_params)
end
def test_message_params
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: 'incoming',
source_id: @messaging[:message][:mid],
content: @messaging[:message][:text],
sender: @contact
}
end
def build_conversation(conversation_params)
Conversation.create!(
conversation_params.merge(
contact_inbox_id: @contact_inbox.id
)
)
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: @contact.id,
additional_attributes: {
type: 'instagram_direct_message'
}
}
end
end

View File

@@ -0,0 +1,59 @@
class Instagram::WebhooksBaseService
attr_reader :channel
def initialize(channel)
@channel = channel
end
private
def inbox_channel(_instagram_id)
@inbox = ::Inbox.find_by(channel: @channel)
end
def find_or_create_contact(user)
@contact_inbox = @inbox.contact_inboxes.where(source_id: user['id']).first
@contact = @contact_inbox.contact if @contact_inbox
update_instagram_profile_link(user) && return if @contact
@contact_inbox = @inbox.channel.create_contact_inbox(
user['id'], user['name']
)
@contact = @contact_inbox.contact
update_instagram_profile_link(user)
Avatar::AvatarFromUrlJob.perform_later(@contact, user['profile_pic']) if user['profile_pic']
end
def update_instagram_profile_link(user)
return unless user['username']
instagram_attributes = build_instagram_attributes(user)
@contact.update!(additional_attributes: @contact.additional_attributes.merge(instagram_attributes))
end
def build_instagram_attributes(user)
attributes = {
# TODO: Remove this once we show the social_instagram_user_name in the UI instead of the username
'social_profiles': { 'instagram': user['username'] },
'social_instagram_user_name': user['username']
}
# Add optional attributes if present
optional_fields = %w[
follower_count
is_user_follow_business
is_business_follow_user
is_verified_user
]
optional_fields.each do |field|
next if user[field].nil?
attributes["social_instagram_#{field}"] = user[field]
end
attributes
end
end

View File

@@ -0,0 +1,34 @@
class Internal::RemoveOrphanConversationsService
def initialize(account: nil, days: 1)
@account = account
@days = days
end
def perform
orphan_conversations = build_orphan_conversations_query
total_deleted = 0
Rails.logger.info '[RemoveOrphanConversationsService] Starting removal of orphan conversations'
orphan_conversations.find_in_batches(batch_size: 1000) do |batch|
conversation_ids = batch.map(&:id)
Conversation.where(id: conversation_ids).destroy_all
total_deleted += batch.size
Rails.logger.info "[RemoveOrphanConversationsService] Deleted #{batch.size} orphan conversations (#{total_deleted} total)"
end
Rails.logger.info "[RemoveOrphanConversationsService] Completed. Total deleted: #{total_deleted}"
total_deleted
end
private
def build_orphan_conversations_query
base = @account ? @account.conversations : Conversation.all
base = base.where('conversations.created_at > ?', @days.days.ago)
base = base.left_outer_joins(:contact, :inbox)
# Find conversations whose associated contact or inbox record is missing
base.where(contacts: { id: nil }).or(base.where(inboxes: { id: nil }))
end
end

View File

@@ -0,0 +1,43 @@
class Internal::RemoveStaleContactInboxesService
def perform
return unless remove_stale_contact_inbox_job_enabled?
time_period = 90.days.ago
contact_inboxes_to_delete = stale_contact_inboxes(time_period)
log_stale_contact_inboxes_deletion(contact_inboxes_to_delete, time_period)
# Since the number of records to delete is very high,
# delete_all would be faster than destroy_all since it operates at database level
# and avoid loading all the records in memory
# Transaction and batching is used to avoid deadlock and memory issues
ContactInbox.transaction do
contact_inboxes_to_delete
.find_in_batches(batch_size: 10_000) do |group|
ContactInbox.where(id: group.map(&:id)).delete_all
end
end
end
private
def remove_stale_contact_inbox_job_enabled?
job_status = ENV.fetch('REMOVE_STALE_CONTACT_INBOX_JOB_STATUS', false)
return false unless ActiveModel::Type::Boolean.new.cast(job_status)
true
end
def stale_contact_inboxes(time_period)
ContactInbox.stale_without_conversations(time_period)
end
def log_stale_contact_inboxes_deletion(contact_inboxes, time_period)
count = contact_inboxes.count
Rails.logger.info "Deleting #{count} stale contact inboxes older than #{time_period}"
# Log the SQL query without executing it
sql_query = contact_inboxes.to_sql
Rails.logger.info("SQL Query: #{sql_query}")
end
end

View File

@@ -0,0 +1,19 @@
class Internal::RemoveStaleContactsService
pattr_initialize [:account!]
def perform(batch_size = 1000)
contacts_to_remove = @account.contacts.stale_without_conversations(30.days.ago)
total_deleted = 0
Rails.logger.info "[Internal::RemoveStaleContactsService] Starting removal of stale contacts for account #{@account.id}"
contacts_to_remove.find_in_batches(batch_size: batch_size) do |batch|
contact_ids = batch.map(&:id)
ContactInbox.where(contact_id: contact_ids).delete_all
Contact.where(id: contact_ids).delete_all
total_deleted += batch.size
Rails.logger.info "[Internal::RemoveStaleContactsService] Deleted #{batch.size} contacts (#{total_deleted} total) for account #{@account.id}"
end
end
end

View File

@@ -0,0 +1,15 @@
class Internal::RemoveStaleRedisKeysService
pattr_initialize [:account_id!]
def perform
Rails.logger.info "Removing redis stale keys for account #{@account_id}"
range_start = (Time.zone.now - OnlineStatusTracker::PRESENCE_DURATION).to_i
# exclusive minimum score is specified by prefixing (
# we are clearing old records because this could clogg up the sorted set
::Redis::Alfred.zremrangebyscore(
OnlineStatusTracker.presence_key(@account_id, 'Contact'),
'-inf',
"(#{range_start}"
)
end
end

View File

@@ -0,0 +1,15 @@
class IpLookupService
def perform(ip_address)
return if ip_address.blank? || !ip_database_available?
Geocoder.search(ip_address).first
rescue Errno::ETIMEDOUT => e
Rails.logger.warn "Exception: IP resolution failed :#{e.message}"
end
private
def ip_database_available?
File.exist?(GeocoderConfiguration::LOOK_UP_DB)
end
end

View File

@@ -0,0 +1,35 @@
class Labels::UpdateService
pattr_initialize [:new_label_title!, :old_label_title!, :account_id!]
def perform
tagged_conversations.find_in_batches do |conversation_batch|
conversation_batch.each do |conversation|
conversation.label_list.remove(old_label_title)
conversation.label_list.add(new_label_title)
conversation.save!
end
end
tagged_contacts.find_in_batches do |contact_batch|
contact_batch.each do |contact|
contact.label_list.remove(old_label_title)
contact.label_list.add(new_label_title)
contact.save!
end
end
end
private
def tagged_conversations
account.conversations.tagged_with(old_label_title)
end
def tagged_contacts
account.contacts.tagged_with(old_label_title)
end
def account
@account ||= Account.find(account_id)
end
end

View File

@@ -0,0 +1,171 @@
# ref : https://developers.line.biz/en/docs/messaging-api/receiving-messages/#webhook-event-types
# https://developers.line.biz/en/reference/messaging-api/#message-event
class Line::IncomingMessageService
include ::FileTypeHelper
pattr_initialize [:inbox!, :params!]
LINE_STICKER_IMAGE_URL = 'https://stickershop.line-scdn.net/stickershop/v1/sticker/%s/android/sticker.png'.freeze
def perform
# probably test events
return if params[:events].blank?
parse_events
end
private
def parse_events
params[:events].each do |event|
next unless event_type_message?(event)
get_line_contact_info(event)
next if @line_contact_info['userId'].blank?
set_contact
set_conversation
next unless message_created? event
attach_files event['message']
@message.save!
end
end
def message_created?(event)
@message = @conversation.messages.build(
content: message_content(event),
account_id: @inbox.account_id,
content_type: message_content_type(event),
inbox_id: @inbox.id,
message_type: :incoming,
sender: @contact,
source_id: event['message']['id'].to_s
)
@message
end
def message_content(event)
message_type = event.dig('message', 'type')
case message_type
when 'text'
event.dig('message', 'text')
when 'sticker'
sticker_id = event.dig('message', 'stickerId')
sticker_image_url(sticker_id)
end
end
# Currently, Chatwoot doesn't support stickers. As a temporary solution,
# we're displaying stickers as images using the sticker ID in markdown format.
# This is subject to change in the future. We've chosen not to download and display the sticker as an image because the sticker's information
# and images are the property of the creator or legal owner. We aim to avoid storing it on our server without their consent.
# If there are any permission or rendering issues, the URL may break, and we'll display the sticker ID as text instead.
# Ref: https://developers.line.biz/en/reference/messaging-api/#wh-sticker
def sticker_image_url(sticker_id)
"![sticker-#{sticker_id}](#{LINE_STICKER_IMAGE_URL % sticker_id})"
end
def message_content_type(event)
return 'sticker' if event['message']['type'] == 'sticker'
'text'
end
def attach_files(message)
return unless message_type_non_text?(message['type'])
response = inbox.channel.client.get_message_content(message['id'])
extension = get_file_extension(response)
file_name = message['fileName'] || "media-#{message['id']}.#{extension}"
temp_file = Tempfile.new(file_name)
temp_file.binmode
temp_file << response.body
temp_file.rewind
@message.attachments.new(
account_id: @message.account_id,
file_type: file_content_type(response),
file: {
io: temp_file,
filename: file_name,
content_type: response.content_type
}
)
end
def get_file_extension(response)
if response.content_type&.include?('/')
response.content_type.split('/')[1]
else
'bin'
end
end
def event_type_message?(event)
event['type'] == 'message' || event['type'] == 'sticker'
end
def message_type_non_text?(type)
[
Line::Bot::Event::MessageType::Video,
Line::Bot::Event::MessageType::Audio,
Line::Bot::Event::MessageType::Image,
Line::Bot::Event::MessageType::File
].include?(type)
end
def account
@account ||= inbox.account
end
def get_line_contact_info(event)
@line_contact_info = JSON.parse(inbox.channel.client.get_profile(event['source']['userId']).body)
end
def set_contact
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: @line_contact_info['userId'],
inbox: inbox,
contact_attributes: contact_attributes
).perform
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: @contact.id,
contact_inbox_id: @contact_inbox.id
}
end
def set_conversation
@conversation = @contact_inbox.conversations.first
return if @conversation
@conversation = ::Conversation.create!(conversation_params)
end
def contact_attributes
{
name: @line_contact_info['displayName'],
avatar_url: @line_contact_info['pictureUrl'],
additional_attributes: additional_attributes
}
end
def additional_attributes
{
social_line_user_id: @line_contact_info['userId']
}
end
def file_content_type(file_content)
file_type(file_content.content_type)
end
end

View File

@@ -0,0 +1,113 @@
class Line::SendOnLineService < Base::SendOnChannelService
private
def channel_class
Channel::Line
end
def perform_reply
response = channel.client.push_message(message.conversation.contact_inbox.source_id, build_payload)
return if response.blank?
parsed_json = JSON.parse(response.body)
if response.code == '200'
# If the request is successful, update the message status to delivered
Messages::StatusUpdateService.new(message, 'delivered').perform
else
# If the request is not successful, update the message status to failed and save the external error
Messages::StatusUpdateService.new(message, 'failed', external_error(parsed_json)).perform
end
end
def build_payload
if message.content_type == 'input_select' && message.content_attributes['items'].any?
build_input_select_payload
else
build_text_payload
end
end
def build_text_payload
if message.content && message.attachments.any?
[text_message, *attachments]
elsif message.content.nil? && message.attachments.any?
attachments
else
text_message
end
end
def attachments
message.attachments.map do |attachment|
# Support only image and video for now, https://developers.line.biz/en/reference/messaging-api/#image-message
next unless attachment.file_type == 'image' || attachment.file_type == 'video'
{
type: attachment.file_type,
originalContentUrl: attachment.download_url,
previewImageUrl: attachment.download_url
}
end
end
# https://developers.line.biz/en/reference/messaging-api/#text-message
def text_message
{
type: 'text',
text: message.outgoing_content
}
end
# https://developers.line.biz/en/reference/messaging-api/#flex-message
def build_input_select_payload
{
type: 'flex',
altText: message.content,
contents: {
type: 'bubble',
body: {
type: 'box',
layout: 'vertical',
contents: [
{
type: 'text',
text: message.content,
wrap: true
},
*input_select_to_button
]
}
}
}
end
def input_select_to_button
message.content_attributes['items'].map do |item|
{
type: 'button',
style: 'link',
height: 'sm',
action: {
type: 'message',
label: item['title'],
text: item['value']
}
}
end
end
# https://developers.line.biz/en/reference/messaging-api/#error-responses
def external_error(error)
# Message containing information about the error. See https://developers.line.biz/en/reference/messaging-api/#error-messages
message = error['message']
# An array of error details. If the array is empty, this property will not be included in the response.
details = error['details']
return message if details.blank?
detail_messages = details.map { |detail| "#{detail['property']}: #{detail['message']}" }
[message, detail_messages].join(', ')
end
end

View File

@@ -0,0 +1,41 @@
class Linear::ActivityMessageService
attr_reader :conversation, :action_type, :issue_data, :user
def initialize(conversation:, action_type:, user:, issue_data: {})
@conversation = conversation
@action_type = action_type
@issue_data = issue_data
@user = user
end
def perform
return unless conversation && issue_data[:id] && user
content = generate_activity_content
return unless content
::Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(content))
end
private
def generate_activity_content
case action_type.to_sym
when :issue_created
I18n.t('conversations.activity.linear.issue_created', user_name: user.name, issue_id: issue_data[:id])
when :issue_linked
I18n.t('conversations.activity.linear.issue_linked', user_name: user.name, issue_id: issue_data[:id])
when :issue_unlinked
I18n.t('conversations.activity.linear.issue_unlinked', user_name: user.name, issue_id: issue_data[:id])
end
end
def activity_message_params(content)
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :activity,
content: content
}
end
end

View File

@@ -0,0 +1,26 @@
class Liquid::CampaignTemplateService
pattr_initialize [:campaign!, :contact!]
def call(message)
process_liquid_in_content(message_drops, message)
end
private
def message_drops
{
'contact' => ContactDrop.new(contact),
'agent' => UserDrop.new(campaign.sender),
'inbox' => InboxDrop.new(campaign.inbox),
'account' => AccountDrop.new(campaign.account)
}
end
def process_liquid_in_content(drops, message)
message = message.gsub(/`(.*?)`/m, '{% raw %}`\\1`{% endraw %}')
template = Liquid::Template.parse(message)
template.render(drops)
rescue Liquid::Error
message
end
end

View File

@@ -0,0 +1,22 @@
class LlmFormatter::ArticleLlmFormatter
attr_reader :article
def initialize(article)
@article = article
end
def format(*)
<<~TEXT
Title: #{article.title}
ID: #{article.id}
Status: #{article.status}
Category: #{article.category&.name || 'Uncategorized'}
Author: #{article.author&.name || 'Unknown'}
Views: #{article.views}
Created At: #{article.created_at}
Updated At: #{article.updated_at}
Content:
#{article.content}
TEXT
end
end

View File

@@ -0,0 +1,35 @@
class LlmFormatter::ContactLlmFormatter < LlmFormatter::DefaultLlmFormatter
def format(*)
sections = []
sections << "Contact ID: ##{@record.id}"
sections << 'Contact Attributes:'
sections << build_attributes
sections << 'Contact Notes:'
sections << if @record.notes.any?
build_notes
else
'No notes for this contact'
end
sections.join("\n")
end
private
def build_notes
@record.notes.all.map { |note| " - #{note.content}" }.join("\n")
end
def build_attributes
attributes = []
attributes << "Name: #{@record.name}"
attributes << "Email: #{@record.email}"
attributes << "Phone: #{@record.phone_number}"
attributes << "Location: #{@record.location}"
attributes << "Country Code: #{@record.country_code}"
@record.account.custom_attribute_definitions.with_attribute_model('contact_attribute').each do |attribute|
attributes << "#{attribute.attribute_display_name}: #{@record.custom_attributes[attribute.attribute_key]}"
end
attributes.join("\n")
end
end

View File

@@ -0,0 +1,86 @@
class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
def format(config = {})
sections = []
sections << "Conversation ID: ##{@record.display_id}"
sections << "Channel: #{@record.inbox.channel.name}"
sections << 'Message History:'
sections << if @record.messages.any?
build_messages(config)
else
'No messages in this conversation'
end
sections << "Contact Details: #{@record.contact.to_llm_text}" if config[:include_contact_details]
attributes = build_attributes
if attributes.present?
sections << 'Conversation Attributes:'
sections << attributes
end
sections.join("\n")
end
private
def build_messages(config = {})
return "No messages in this conversation\n" if @record.messages.empty?
messages = @record.messages.where.not(message_type: [:activity, :template])
if config[:token_limit]
build_limited_messages(messages, config)
else
build_all_messages(messages, config)
end
end
def build_all_messages(messages, config)
message_text = ''
messages.order(created_at: :asc).each do |message|
# Skip private messages unless explicitly included in config
next if message.private? && !config[:include_private_messages]
message_text << format_message(message)
end
message_text
end
def build_limited_messages(messages, config)
selected = []
character_count = 0
messages.reorder(created_at: :desc).each do |message|
# Skip private messages unless explicitly included in config
next if message.private? && !config[:include_private_messages]
formatted = format_message(message)
break if character_count + formatted.length > config[:token_limit]
selected.prepend(formatted)
character_count += formatted.length
end
selected.join
end
def format_message(message)
sender = case message.sender_type
when 'User'
'Support Agent'
when 'Contact'
'User'
else
'Bot'
end
sender = "[Private Note] #{sender}" if message.private?
"#{sender}: #{message.content_for_llm}\n"
end
def build_attributes
attributes = @record.account.custom_attribute_definitions.with_attribute_model('conversation_attribute').map do |attribute|
"#{attribute.attribute_display_name}: #{@record.custom_attributes[attribute.attribute_key]}"
end
attributes.join("\n")
end
end

View File

@@ -0,0 +1,9 @@
class LlmFormatter::DefaultLlmFormatter
def initialize(record)
@record = record
end
def format(*)
# override this
end
end

View File

@@ -0,0 +1,20 @@
class LlmFormatter::LlmTextFormatterService
def initialize(record)
@record = record
end
def format(config = {})
formatter_class = find_formatter
formatter_class.new(@record).format(config)
end
private
def find_formatter
formatter_name = "LlmFormatter::#{@record.class.name}LlmFormatter"
formatter_class = formatter_name.safe_constantize
raise FormatterNotFoundError, "No formatter found for #{@record.class.name}" unless formatter_class
formatter_class
end
end

View File

@@ -0,0 +1,70 @@
class Macros::ExecutionService < ActionService
def initialize(macro, conversation, user)
super(conversation)
@macro = macro
@account = macro.account
@user = user
Current.user = user
end
def perform
@macro.actions.each do |action|
action = action.with_indifferent_access
begin
send(action[:action_name], action[:action_params])
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @account).capture_exception
end
end
ensure
Current.reset
end
private
def assign_agent(agent_ids)
agent_ids = agent_ids.map { |id| id == 'self' ? @user.id : id }
super(agent_ids)
end
def add_private_note(message)
return if conversation_a_tweet?
params = { content: message[0], private: true }
# Added reload here to ensure conversation us persistent with the latest updates
mb = Messages::MessageBuilder.new(@user, @conversation.reload, params)
mb.perform
end
def send_message(message)
return if conversation_a_tweet?
params = { content: message[0], private: false }
# Added reload here to ensure conversation us persistent with the latest updates
mb = Messages::MessageBuilder.new(@user, @conversation.reload, params)
mb.perform
end
def send_attachment(blob_ids)
return if conversation_a_tweet?
return unless @macro.files.attached?
blobs = ActiveStorage::Blob.where(id: blob_ids)
return if blobs.blank?
params = { content: nil, private: false, attachments: blobs }
# Added reload here to ensure conversation us persistent with the latest updates
mb = Messages::MessageBuilder.new(@user, @conversation.reload, params)
mb.perform
end
def send_webhook_event(webhook_url)
payload = @conversation.webhook_data.merge(event: 'macro.executed')
WebhookJob.perform_later(webhook_url.first, payload)
end
end

View File

@@ -0,0 +1,29 @@
class Mailbox::ConversationFinder
DEFAULT_STRATEGIES = [
Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy,
Mailbox::ConversationFinderStrategies::InReplyToStrategy,
Mailbox::ConversationFinderStrategies::ReferencesStrategy,
Mailbox::ConversationFinderStrategies::NewConversationStrategy
].freeze
def initialize(mail, strategies: DEFAULT_STRATEGIES)
@mail = mail
@strategies = strategies
end
def find
@strategies.each do |strategy_class|
conversation = strategy_class.new(@mail).find
next unless conversation
strategy_name = strategy_class.name.demodulize.underscore
Rails.logger.info "Conversation found via #{strategy_name} strategy"
return conversation
end
# Should not reach here if NewConversationStrategy is in the chain
Rails.logger.error 'No conversation found via any strategy (NewConversationStrategy missing?)'
nil
end
end

View File

@@ -0,0 +1,13 @@
class Mailbox::ConversationFinderStrategies::BaseStrategy
attr_reader :mail
def initialize(mail)
@mail = mail
end
# Returns Conversation or nil
# Subclasses must implement this method
def find
raise NotImplementedError, "#{self.class} must implement #find"
end
end

View File

@@ -0,0 +1,48 @@
class Mailbox::ConversationFinderStrategies::InReplyToStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
# Patterns from ApplicationMailbox
MESSAGE_PATTERN = %r{conversation/([a-zA-Z0-9-]+)/messages/(\d+)@}
# FALLBACK_PATTERN is used when building In-Reply-To headers in ConversationReplyMailer
# when there's no actual message to reply to (see app/mailers/conversation_reply_mailer.rb#in_reply_to_email).
# This happens when:
# - A conversation is started by an agent (no incoming message yet)
# - The conversation originated from a non-email channel (widget, WhatsApp, etc.) but is now using email
# - The incoming message doesn't have email metadata with a message_id
# In these cases, we use a conversation-level identifier instead of a message-level one.
FALLBACK_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@}
def find
return nil if mail.in_reply_to.blank?
in_reply_to_addresses = Array.wrap(mail.in_reply_to)
in_reply_to_addresses.each do |in_reply_to|
# Try extracting UUID from patterns
uuid = extract_uuid_from_patterns(in_reply_to)
if uuid
conversation = Conversation.find_by(uuid: uuid)
return conversation if conversation
end
# Try finding by message source_id
message = Message.find_by(source_id: in_reply_to)
return message.conversation if message&.conversation
end
nil
end
private
def extract_uuid_from_patterns(message_id)
# Try message-specific pattern first
match = MESSAGE_PATTERN.match(message_id)
return match[1] if match
# Try conversation fallback pattern
match = FALLBACK_PATTERN.match(message_id)
return match[2] if match
nil
end
end

View File

@@ -0,0 +1,83 @@
class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
include MailboxHelper
include IncomingEmailValidityHelper
attr_accessor :processed_mail, :account, :inbox, :contact, :contact_inbox, :conversation, :channel
def initialize(mail)
super(mail)
@channel = EmailChannelFinder.new(mail).perform
return unless @channel
@account = @channel.account
@inbox = @channel.inbox
@processed_mail = MailPresenter.new(mail, @account)
end
# This strategy prepares a new conversation but doesn't persist it yet.
# Why we don't use create! here:
# - Avoids orphan conversations if message/attachment creation fails later
# - Prevents duplicate conversations on job retry (no idempotency issue)
# - Follows the pattern from old SupportMailbox where everything was in one transaction
# The actual persistence happens in ReplyMailbox within a transaction that includes message creation.
def find
return nil unless @channel # No valid channel found
return nil unless incoming_email_from_valid_email? # Skip edge cases
# Check if conversation already exists by in_reply_to
existing_conversation = find_conversation_by_in_reply_to
return existing_conversation if existing_conversation
# Prepare contact (persisted) and build conversation (not persisted)
find_or_create_contact
build_conversation
end
private
def find_or_create_contact
@contact = @inbox.contacts.from_email(original_sender_email)
if @contact.present?
@contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
else
create_contact
end
end
def original_sender_email
@processed_mail.original_sender&.downcase
end
def identify_contact_name
@processed_mail.sender_name || @processed_mail.from.first.split('@').first
end
def build_conversation
# Build but don't persist - ReplyMailbox will save in transaction with message
@conversation = ::Conversation.new(
account_id: @account.id,
inbox_id: @inbox.id,
contact_id: @contact.id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: {
in_reply_to: in_reply_to,
source: 'email',
auto_reply: @processed_mail.auto_reply?,
mail_subject: @processed_mail.subject,
initiated_at: {
timestamp: Time.now.utc
}
}
)
end
def in_reply_to
mail['In-Reply-To'].try(:value)
end
def find_conversation_by_in_reply_to
return if in_reply_to.blank?
@account.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first
end
end

View File

@@ -0,0 +1,26 @@
class Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
# Pattern from ApplicationMailbox::REPLY_EMAIL_UUID_PATTERN
UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
def find
uuid = extract_uuid_from_receivers
return nil unless uuid
Conversation.find_by(uuid: uuid)
end
private
def extract_uuid_from_receivers
mail_presenter = MailPresenter.new(mail)
return nil if mail_presenter.mail_receiver.blank?
mail_presenter.mail_receiver.each do |email|
username = email.split('@').first
match = username.match(UUID_PATTERN)
return match[1] if match
end
nil
end
end

View File

@@ -0,0 +1,59 @@
class Mailbox::ConversationFinderStrategies::ReferencesStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
# Patterns from ApplicationMailbox
MESSAGE_PATTERN = %r{conversation/([a-zA-Z0-9-]+)/messages/(\d+)@}
# FALLBACK_PATTERN is used when building References headers in ConversationReplyMailer
# when there's no actual message to reply to (see app/mailers/conversation_reply_mailer.rb#in_reply_to_email).
# This happens when:
# - A conversation is started by an agent (no incoming message yet)
# - The conversation originated from a non-email channel (widget, WhatsApp, etc.) but is now using email
# - The incoming message doesn't have email metadata with a message_id
# In these cases, we use a conversation-level identifier instead of a message-level one.
FALLBACK_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@}
def initialize(mail)
super(mail)
# Get channel once upfront to use for scoped queries
@channel = EmailChannelFinder.new(mail).perform
end
def find
return nil if mail.references.blank?
return nil unless @channel # No valid channel found
references = Array.wrap(mail.references)
references.each do |reference|
conversation = find_conversation_from_reference(reference)
return conversation if conversation
end
nil
end
private
def find_conversation_from_reference(reference)
# Try extracting UUID from patterns
uuid = extract_uuid_from_patterns(reference)
if uuid
# Query scoped to inbox - prevents cross-account/cross-inbox matches at database level
conversation = Conversation.find_by(uuid: uuid, inbox_id: @channel.inbox.id)
return conversation if conversation
end
# We scope to the inbox, that way we filter out messages and conversations that don't belong to the channel
message = Message.find_by(source_id: reference, inbox_id: @channel.inbox.id)
message&.conversation
end
def extract_uuid_from_patterns(message_id)
match = MESSAGE_PATTERN.match(message_id)
return match[1] if match
match = FALLBACK_PATTERN.match(message_id)
return match[2] if match
nil
end
end

View File

@@ -0,0 +1,62 @@
class MessageTemplates::HookExecutionService
pattr_initialize [:message!]
def perform
return if conversation.last_incoming_message.blank?
return if message.auto_reply_email?
trigger_templates
end
private
delegate :inbox, :conversation, to: :message
delegate :contact, to: :conversation
def trigger_templates
::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message?
::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting?
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect?
end
def should_send_out_of_office_message?
return false if conversation.campaign.present?
# should not send if its a tweet message
return false if conversation.tweet?
# should not send for outbound messages
return false unless message.incoming?
# prevents sending out-of-office message if an agent has sent a message in last 5 minutes
# ensures better UX by not interrupting active conversations at the end of business hours
return false if conversation.messages.outgoing.where(private: false).exists?(['created_at > ?', 5.minutes.ago])
inbox.out_of_office? && conversation.messages.today.template.empty? && inbox.out_of_office_message.present?
end
def first_message_from_contact?
conversation.messages.outgoing.count.zero? && conversation.messages.template.count.zero?
end
def should_send_greeting?
return false if conversation.campaign.present?
# should not send if its a tweet message
return false if conversation.tweet?
first_message_from_contact? && inbox.greeting_enabled? && inbox.greeting_message.present?
end
def email_collect_was_sent?
conversation.messages.where(content_type: 'input_email').present?
end
# TODO: we should be able to reduce this logic once we have a toggle for email collect messages
def should_send_email_collect?
return false if conversation.campaign.present?
!contact_has_email? && inbox.web_widget? && !email_collect_was_sent?
end
def contact_has_email?
contact.email
end
end
MessageTemplates::HookExecutionService.prepend_mod_with('MessageTemplates::HookExecutionService')

View File

@@ -0,0 +1,42 @@
class MessageTemplates::Template::AutoResolve
pattr_initialize [:conversation!]
def perform
return if conversation.account.auto_resolve_message.blank?
if within_messaging_window?
conversation.messages.create!(auto_resolve_message_params)
else
create_auto_resolve_not_sent_activity_message
end
end
private
delegate :contact, :account, to: :conversation
delegate :inbox, to: :message
def within_messaging_window?
conversation.can_reply?
end
def create_auto_resolve_not_sent_activity_message
content = I18n.t('conversations.activity.auto_resolve.not_sent_due_to_messaging_window')
activity_message_params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :activity,
content: content
}
::Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params) if content
end
def auto_resolve_message_params
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: :template,
content: account.auto_resolve_message
}
end
end

View File

@@ -0,0 +1,40 @@
class MessageTemplates::Template::CsatSurvey
pattr_initialize [:conversation!]
def perform
ActiveRecord::Base.transaction do
conversation.messages.create!(csat_survey_message_params)
end
end
private
delegate :contact, :account, :inbox, to: :conversation
def message_content
return I18n.t('conversations.templates.csat_input_message_body') if csat_config.blank? || csat_config['message'].blank?
csat_config['message']
end
def csat_survey_message_params
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: :template,
content_type: :input_csat,
content: message_content,
content_attributes: content_attributes
}
end
def csat_config
inbox.csat_config || {}
end
def content_attributes
{
display_type: csat_config['display_type'] || 'emoji'
}
end
end

View File

@@ -0,0 +1,43 @@
class MessageTemplates::Template::EmailCollect
pattr_initialize [:conversation!]
def perform
ActiveRecord::Base.transaction do
conversation.messages.create!(ways_to_reach_you_message_params)
conversation.messages.create!(email_input_box_template_message_params)
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
true
end
private
delegate :contact, :account, to: :conversation
delegate :inbox, to: :message
def ways_to_reach_you_message_params
content = I18n.t('conversations.templates.ways_to_reach_you_message_body',
account_name: account.name)
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: :template,
content: content
}
end
def email_input_box_template_message_params
content = I18n.t('conversations.templates.email_input_box_message_body',
account_name: account.name)
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: :template,
content_type: :input_email,
content: content
}
end
end

View File

@@ -0,0 +1,28 @@
class MessageTemplates::Template::Greeting
pattr_initialize [:conversation!]
def perform
ActiveRecord::Base.transaction do
conversation.messages.create!(greeting_message_params)
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
true
end
private
delegate :contact, :account, to: :conversation
delegate :inbox, to: :message
def greeting_message_params
content = @conversation.inbox&.greeting_message
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: :template,
content: content
}
end
end

View File

@@ -0,0 +1,36 @@
class MessageTemplates::Template::OutOfOffice
pattr_initialize [:conversation!]
def self.perform_if_applicable(conversation)
inbox = conversation.inbox
return unless inbox.out_of_office?
return if inbox.out_of_office_message.blank?
new(conversation: conversation).perform
end
def perform
ActiveRecord::Base.transaction do
conversation.messages.create!(out_of_office_message_params)
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
true
end
private
delegate :contact, :account, to: :conversation
delegate :inbox, to: :message
def out_of_office_message_params
content = @conversation.inbox&.out_of_office_message
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: :template,
content: content
}
end
end

View File

@@ -0,0 +1,24 @@
class Messages::InReplyToMessageBuilder
pattr_initialize [:message!, :in_reply_to!, :in_reply_to_external_id!]
delegate :conversation, to: :message
def perform
set_in_reply_to_attribute if @in_reply_to.present? || @in_reply_to_external_id.present?
end
private
def set_in_reply_to_attribute
@message.content_attributes[:in_reply_to_external_id] = in_reply_to_message.try(:source_id)
@message.content_attributes[:in_reply_to] = in_reply_to_message.try(:id)
end
def in_reply_to_message
return conversation.messages.find_by(id: @in_reply_to) if @in_reply_to.present?
return conversation.messages.find_by(source_id: @in_reply_to_external_id) if @in_reply_to_external_id
nil
end
end

View File

@@ -0,0 +1,113 @@
class Messages::MarkdownRendererService
CHANNEL_RENDERERS = {
'Channel::Email' => :render_html,
'Channel::WebWidget' => :render_html,
'Channel::Telegram' => :render_telegram_html,
'Channel::Whatsapp' => :render_whatsapp,
'Channel::FacebookPage' => :render_instagram,
'Channel::Instagram' => :render_instagram,
'Channel::Line' => :render_line,
'Channel::TwitterProfile' => :render_plain_text,
'Channel::Sms' => :render_plain_text,
'Channel::TwilioSms' => :render_plain_text
}.freeze
def initialize(content, channel_type, channel = nil)
@content = content
@channel_type = channel_type
@channel = channel
end
def render
return @content if @content.blank?
renderer_method = CHANNEL_RENDERERS[effective_channel_type]
renderer_method ? send(renderer_method) : @content
end
private
def effective_channel_type
# For Twilio SMS channel, check if it's actually WhatsApp
if @channel_type == 'Channel::TwilioSms' && @channel&.whatsapp?
'Channel::Whatsapp'
else
@channel_type
end
end
def commonmarker_doc
@commonmarker_doc ||= CommonMarker.render_doc(@content, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
end
def render_html
markdown_renderer = BaseMarkdownRenderer.new
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough])
markdown_renderer.render(doc)
end
def render_telegram_html
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::TelegramRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:STRIKETHROUGH_DOUBLE_TILDE], [:strikethrough])
result = renderer.render(doc).gsub(/\n+\z/, '')
restore_multiple_newlines(result)
end
def render_whatsapp
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::WhatsAppRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
result = renderer.render(doc).gsub(/\n+\z/, '')
restore_multiple_newlines(result)
end
def render_instagram
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::InstagramRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
result = renderer.render(doc).gsub(/\n+\z/, '')
restore_multiple_newlines(result)
end
def render_line
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::LineRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
result = renderer.render(doc).gsub(/\n+\z/, '')
restore_multiple_newlines(result)
end
def render_plain_text
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::PlainTextRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
result = renderer.render(doc).gsub(/\n+\z/, '')
restore_multiple_newlines(result)
end
# Preserve multiple consecutive newlines (2+) by replacing them with placeholders
# Standard markdown treats 2 newlines as paragraph break which collapses to 1 newline, we preserve 2+
def preserve_multiple_newlines(content)
content.gsub(/\n{2,}/) do |match|
"{{PRESERVE_#{match.length}_NEWLINES}}"
end
end
# Restore multiple newlines from placeholders
def restore_multiple_newlines(content)
content.gsub(/\{\{PRESERVE_(\d+)_NEWLINES\}\}/) do |_match|
"\n" * Regexp.last_match(1).to_i
end
end
end

View File

@@ -0,0 +1,39 @@
class Messages::MarkdownRenderers::BaseMarkdownRenderer < CommonMarker::Renderer
def document(_node)
out(:children)
end
def paragraph(_node)
out(:children)
cr
end
def text(node)
out(node.string_content)
end
def softbreak(_node)
out(' ')
end
def linebreak(_node)
out("\n")
end
def strikethrough(_node)
out('<del>')
out(:children)
out('</del>')
end
def method_missing(method_name, node = nil, *args, **kwargs, &)
return super unless node.is_a?(CommonMarker::Node)
out(:children)
cr unless %i[text softbreak linebreak].include?(node.type)
end
def respond_to_missing?(_method_name, _include_private = false)
true
end
end

View File

@@ -0,0 +1,52 @@
class Messages::MarkdownRenderers::InstagramRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def initialize
super
@list_item_number = 0
end
def strong(_node)
out('*', :children, '*')
end
def emph(_node)
out('_', :children, '_')
end
def code(node)
out(node.string_content)
end
def link(node)
out(node.url)
end
def list(node)
@list_type = node.list_type
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
out(:children)
cr
end
def list_item(_node)
if @list_type == :ordered_list
out("#{@list_item_number}. ", :children)
@list_item_number += 1
else
out('- ', :children)
end
cr
end
def blockquote(_node)
out(:children)
cr
end
def code_block(node)
out(node.string_content)
end
def softbreak(_node)
out("\n")
end
end

View File

@@ -0,0 +1,36 @@
class Messages::MarkdownRenderers::LineRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def strong(_node)
out(' *', :children, '* ')
end
def emph(_node)
out(' _', :children, '_ ')
end
def code(node)
out(' `', node.string_content, '` ')
end
def link(node)
out(node.url)
end
def list(_node)
out(:children)
cr
end
def list_item(_node)
out(:children)
cr
end
def code_block(node)
out(' ```', "\n", node.string_content, '``` ', "\n")
end
def blockquote(_node)
out(:children)
cr
end
end

View File

@@ -0,0 +1,62 @@
class Messages::MarkdownRenderers::PlainTextRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def initialize
super
@list_item_number = 0
end
def link(node)
out(:children)
out(' ', node.url) if node.url.present?
end
def strong(_node)
out(:children)
end
def emph(_node)
out(:children)
end
def code(node)
out(node.string_content)
end
def list(node)
@list_type = node.list_type
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
out(:children)
cr
end
def list_item(_node)
if @list_type == :ordered_list
out("#{@list_item_number}. ", :children)
@list_item_number += 1
else
out('- ', :children)
end
cr
end
def blockquote(_node)
out(:children)
cr
end
def code_block(node)
out(node.string_content, "\n")
end
def header(_node)
out(:children)
cr
end
def thematic_break(_node)
out("\n")
end
def softbreak(_node)
out("\n")
end
end

View File

@@ -0,0 +1,60 @@
class Messages::MarkdownRenderers::TelegramRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def initialize
super
@list_item_number = 0
end
def strong(_node)
out('<strong>', :children, '</strong>')
end
def emph(_node)
out('<em>', :children, '</em>')
end
def code(node)
out('<code>', node.string_content, '</code>')
end
def link(node)
out('<a href="', node.url, '">', :children, '</a>')
end
def strikethrough(_node)
out('<del>', :children, '</del>')
end
def blockquote(_node)
out('<blockquote>', :children, '</blockquote>')
end
def code_block(node)
out('<pre>', node.string_content, '</pre>')
end
def list(node)
@list_type = node.list_type
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
out(:children)
cr
end
def list_item(_node)
if @list_type == :ordered_list
out("#{@list_item_number}. ", :children)
@list_item_number += 1
else
out('• ', :children)
end
cr
end
def header(_node)
out('<strong>', :children, '</strong>')
cr
end
def softbreak(_node)
out("\n")
end
end

View File

@@ -0,0 +1,52 @@
class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def initialize
super
@list_item_number = 0
end
def strong(_node)
out('*', :children, '*')
end
def emph(_node)
out('_', :children, '_')
end
def code(node)
out('`', node.string_content, '`')
end
def link(node)
out(node.url)
end
def list(node)
@list_type = node.list_type
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
out(:children)
cr
end
def list_item(_node)
if @list_type == :ordered_list
out("#{@list_item_number}. ", :children)
@list_item_number += 1
else
out('- ', :children)
end
cr
end
def blockquote(_node)
out('> ', :children)
cr
end
def code_block(node)
out(node.string_content)
end
def softbreak(_node)
out("\n")
end
end

View File

@@ -0,0 +1,68 @@
class Messages::MentionService
pattr_initialize [:message!]
def perform
return unless valid_mention_message?(message)
validated_mentioned_ids = filter_mentioned_ids_by_inbox
return if validated_mentioned_ids.blank?
Conversations::UserMentionJob.perform_later(validated_mentioned_ids, message.conversation.id, message.account.id)
generate_notifications_for_mentions(validated_mentioned_ids)
add_mentioned_users_as_participants(validated_mentioned_ids)
end
private
def valid_mention_message?(message)
message.private? && message.content.present? && mentioned_ids.present?
end
def mentioned_ids
user_mentions = message.content.scan(%r{\(mention://user/(\d+)/(.+?)\)}).map(&:first)
team_mentions = message.content.scan(%r{\(mention://team/(\d+)/(.+?)\)}).map(&:first)
expanded_user_ids = expand_team_mentions_to_users(team_mentions)
(user_mentions + expanded_user_ids).uniq
end
def expand_team_mentions_to_users(team_ids)
return [] if team_ids.blank?
message.inbox.account.teams
.joins(:team_members)
.where(id: team_ids)
.pluck('team_members.user_id')
.map(&:to_s)
end
def valid_mentionable_user_ids
@valid_mentionable_user_ids ||= begin
inbox = message.inbox
inbox.account.administrators.pluck(:id) + inbox.members.pluck(:id)
end
end
def filter_mentioned_ids_by_inbox
mentioned_ids & valid_mentionable_user_ids.map(&:to_s)
end
def generate_notifications_for_mentions(validated_mentioned_ids)
validated_mentioned_ids.each do |user_id|
NotificationBuilder.new(
notification_type: 'conversation_mention',
user: User.find(user_id),
account: message.account,
primary_actor: message.conversation,
secondary_actor: message
).perform
end
end
def add_mentioned_users_as_participants(validated_mentioned_ids)
validated_mentioned_ids.each do |user_id|
message.conversation.conversation_participants.find_or_create_by(user_id: user_id)
end
end
end

View File

@@ -0,0 +1,51 @@
class Messages::NewMessageNotificationService
pattr_initialize [:message!]
def perform
return unless message.notifiable?
notify_conversation_assignee
notify_participating_users
end
private
delegate :conversation, :sender, :account, to: :message
def notify_conversation_assignee
return if conversation.assignee.blank?
return if already_notified?(conversation.assignee)
return if conversation.assignee == sender
NotificationBuilder.new(
notification_type: 'assigned_conversation_new_message',
user: conversation.assignee,
account: account,
primary_actor: message.conversation,
secondary_actor: message
).perform
end
def notify_participating_users
participating_users = conversation.conversation_participants.map(&:user)
participating_users -= [sender] if sender.is_a?(User)
participating_users.uniq.each do |participant|
next if already_notified?(participant)
NotificationBuilder.new(
notification_type: 'participating_conversation_new_message',
user: participant,
account: account,
primary_actor: message.conversation,
secondary_actor: message
).perform
end
end
# The user could already have been notified via a mention or via assignment
# So we don't need to notify them again
def already_notified?(user)
conversation.notifications.exists?(user: user, secondary_actor: message)
end
end

View File

@@ -0,0 +1,40 @@
class Messages::SendEmailNotificationService
pattr_initialize [:message!]
def perform
return unless should_send_email_notification?
conversation = message.conversation
conversation_mail_key = format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
# Atomically set redis key to prevent duplicate email workers. Keep the key alive longer than
# the worker delay (1 hour) so slow queues don't enqueue duplicate jobs, but let it expire if
# the worker never manages to clean up.
return unless Redis::Alfred.set(conversation_mail_key, message.id, nx: true, ex: 1.hour.to_i)
ConversationReplyEmailJob.set(wait: 2.minutes).perform_later(conversation.id, message.id)
message.account.increment_email_sent_count
end
private
def should_send_email_notification?
return false unless message.email_notifiable_message?
return false if message.conversation.contact.email.blank?
return false unless message.account.within_email_rate_limit?
email_reply_enabled?
end
def email_reply_enabled?
inbox = message.inbox
case inbox.channel.class.to_s
when 'Channel::WebWidget'
inbox.channel.continuity_via_email
when 'Channel::Api'
inbox.account.feature_enabled?('email_continuity_on_api_channel')
else
false
end
end
end

View File

@@ -0,0 +1,34 @@
class Messages::StatusUpdateService
attr_reader :message, :status, :external_error
def initialize(message, status, external_error = nil)
@message = message
@status = status
@external_error = external_error
end
def perform
return false unless valid_status_transition?
update_message_status
end
private
def update_message_status
# Update status and set external_error only when failed
message.update!(
status: status,
external_error: (status == 'failed' ? external_error : nil)
)
end
def valid_status_transition?
return false unless Message.statuses.key?(status)
# Don't allow changing from 'read' to 'delivered'
return false if message.read? && status == 'delivered'
true
end
end

View File

@@ -0,0 +1,23 @@
class Mfa::AuthenticationService
pattr_initialize [:user!, :otp_code, :backup_code]
def authenticate
return false unless user
return authenticate_with_otp if otp_code.present?
return authenticate_with_backup_code if backup_code.present?
false
end
private
def authenticate_with_otp
user.validate_and_consume_otp!(otp_code)
end
def authenticate_with_backup_code
mfa_service = Mfa::ManagementService.new(user: user)
mfa_service.validate_backup_code!(backup_code)
end
end

View File

@@ -0,0 +1,88 @@
class Mfa::ManagementService
pattr_initialize [:user!]
def enable_two_factor!
user.otp_secret = User.generate_otp_secret
user.save!
end
def disable_two_factor!
user.otp_secret = nil
user.otp_required_for_login = false
user.otp_backup_codes = nil
user.save!
end
def verify_and_activate!
ActiveRecord::Base.transaction do
user.update!(otp_required_for_login: true)
backup_codes_generated? ? nil : generate_backup_codes!
end
end
def two_factor_provisioning_uri
return nil if user.otp_secret.blank?
issuer = 'Chatwoot'
label = user.email
user.otp_provisioning_uri(label, issuer: issuer)
end
def generate_backup_codes!
codes = Array.new(10) { SecureRandom.hex(4).upcase }
user.otp_backup_codes = codes
user.save!
codes
end
def validate_backup_code!(code)
return false unless valid_backup_code_input?(code)
codes = user.otp_backup_codes
found_index = find_matching_code_index(codes, code)
return false if found_index.nil?
mark_code_as_used(codes, found_index)
end
private
def valid_backup_code_input?(code)
user.otp_backup_codes.present? && code.present?
end
def find_matching_code_index(codes, code)
found_index = nil
# Constant-time comparison to prevent timing attacks
codes.each_with_index do |stored_code, idx|
is_match = ActiveSupport::SecurityUtils.secure_compare(stored_code, code)
is_unused = stored_code != 'XXXXXXXX'
found_index = idx if is_match && is_unused
end
found_index
end
def mark_code_as_used(codes, index)
codes[index] = 'XXXXXXXX'
user.otp_backup_codes = codes
user.save!
true
end
public
def backup_codes_generated?
user.otp_backup_codes.present?
end
def mfa_enabled?
user.otp_required_for_login?
end
def two_factor_setup_pending?
user.otp_secret.present? && !user.otp_required_for_login?
end
end

Some files were not shown because too many files have changed in this diff Show More