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