feat: integrate LeadSquared CRM (#11284)
This commit is contained in:
@@ -163,9 +163,16 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
@contact.custom_attributes
|
||||
end
|
||||
|
||||
def contact_additional_attributes
|
||||
return @contact.additional_attributes.merge(permitted_params[:additional_attributes]) if permitted_params[:additional_attributes]
|
||||
|
||||
@contact.additional_attributes
|
||||
end
|
||||
|
||||
def contact_update_params
|
||||
# we want the merged custom attributes not the original one
|
||||
permitted_params.except(:custom_attributes, :avatar_url).merge({ custom_attributes: contact_custom_attributes })
|
||||
permitted_params.except(:custom_attributes, :avatar_url)
|
||||
.merge({ custom_attributes: contact_custom_attributes })
|
||||
.merge({ additional_attributes: contact_additional_attributes })
|
||||
end
|
||||
|
||||
def set_include_contact_inboxes
|
||||
|
||||
@@ -80,7 +80,7 @@ export default {
|
||||
}, {});
|
||||
|
||||
this.formItems.forEach(item => {
|
||||
if (item.validation.includes('JSON')) {
|
||||
if (item.validation?.includes('JSON')) {
|
||||
hookPayload.settings[item.name] = JSON.parse(
|
||||
hookPayload.settings[item.name]
|
||||
);
|
||||
@@ -117,7 +117,7 @@ export default {
|
||||
<div class="flex flex-col h-auto overflow-auto integration-hooks">
|
||||
<woot-modal-header
|
||||
:header-title="integration.name"
|
||||
:header-content="integration.description"
|
||||
:header-content="integration.short_description || integration.description"
|
||||
/>
|
||||
<FormKit
|
||||
v-model="values"
|
||||
@@ -169,6 +169,10 @@ export default {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.formkit-form .formkit-help {
|
||||
@apply text-n-slate-10 text-sm font-normal mt-2 w-full;
|
||||
}
|
||||
|
||||
/* equivalent of .reset-base */
|
||||
.formkit-input {
|
||||
margin-bottom: 0px !important;
|
||||
|
||||
36
app/jobs/crm/setup_job.rb
Normal file
36
app/jobs/crm/setup_job.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
class Crm::SetupJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(hook_id)
|
||||
hook = Integrations::Hook.find_by(id: hook_id)
|
||||
|
||||
return if hook.blank? || hook.disabled?
|
||||
|
||||
begin
|
||||
setup_service = create_setup_service(hook)
|
||||
return if setup_service.nil?
|
||||
|
||||
setup_service.setup
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: hook.account).capture_exception
|
||||
Rails.logger.error "Error in CRM setup for hook ##{hook_id} (#{hook.app_id}): #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_setup_service(hook)
|
||||
case hook.app_id
|
||||
when 'leadsquared'
|
||||
Crm::Leadsquared::SetupService.new(hook)
|
||||
# Add cases for future CRMs here
|
||||
# when 'hubspot'
|
||||
# Crm::Hubspot::SetupService.new(hook)
|
||||
# when 'zoho'
|
||||
# Crm::Zoho::SetupService.new(hook)
|
||||
else
|
||||
Rails.logger.error "Unsupported CRM app_id: #{hook.app_id}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
class HookJob < ApplicationJob
|
||||
class HookJob < MutexApplicationJob
|
||||
retry_on LockAcquisitionError, wait: 3.seconds, attempts: 3
|
||||
|
||||
queue_as :medium
|
||||
|
||||
def perform(hook, event_name, event_data = {})
|
||||
@@ -11,6 +13,8 @@ class HookJob < ApplicationJob
|
||||
process_dialogflow_integration(hook, event_name, event_data)
|
||||
when 'google_translate'
|
||||
google_translate_integration(hook, event_name, event_data)
|
||||
when 'leadsquared'
|
||||
process_leadsquared_integration_with_lock(hook, event_name, event_data)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
@@ -41,4 +45,39 @@ class HookJob < ApplicationJob
|
||||
message = event_data[:message]
|
||||
Integrations::GoogleTranslate::DetectLanguageService.new(hook: hook, message: message).perform
|
||||
end
|
||||
|
||||
def process_leadsquared_integration_with_lock(hook, event_name, event_data)
|
||||
# Why do we need a mutex here? glad you asked
|
||||
# When a new conversation is created. We get a contact created event, immediately followed by
|
||||
# a contact updated event, and then a conversation created event.
|
||||
# This all happens within milliseconds of each other.
|
||||
# Now each of these subsequent event handlers need to have a leadsquared lead created and the contact to have the ID.
|
||||
# If the lead data is not present, we try to search the API and create a new lead if it doesn't exist.
|
||||
# This gives us a bad race condition that allows the API to create multiple leads for the same contact.
|
||||
#
|
||||
# This would have not been a problem if the email and phone number were unique identifiers for contacts at LeadSquared
|
||||
# But then this is configurable in the LeadSquared settings, and may or may not be unique.
|
||||
valid_event_names = ['contact.updated', 'conversation.created', 'conversation.resolved']
|
||||
return unless valid_event_names.include?(event_name)
|
||||
return unless hook.feature_allowed?
|
||||
|
||||
key = format(::Redis::Alfred::CRM_PROCESS_MUTEX, hook_id: hook.id)
|
||||
with_lock(key) do
|
||||
process_leadsquared_integration(hook, event_name, event_data)
|
||||
end
|
||||
end
|
||||
|
||||
def process_leadsquared_integration(hook, event_name, event_data)
|
||||
# Process the event with the processor service
|
||||
processor = Crm::Leadsquared::ProcessorService.new(hook)
|
||||
|
||||
case event_name
|
||||
when 'contact.updated'
|
||||
processor.handle_contact(event_data[:contact])
|
||||
when 'conversation.created'
|
||||
processor.handle_conversation_created(event_data[:conversation])
|
||||
when 'conversation.resolved'
|
||||
processor.handle_conversation_resolved(event_data[:conversation])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,6 +11,29 @@ class HookListener < BaseListener
|
||||
execute_hooks(event, message)
|
||||
end
|
||||
|
||||
def contact_created(event)
|
||||
contact = extract_contact_and_account(event)[0]
|
||||
execute_account_hooks(event, contact.account, contact: contact)
|
||||
end
|
||||
|
||||
def contact_updated(event)
|
||||
contact = extract_contact_and_account(event)[0]
|
||||
execute_account_hooks(event, contact.account, contact: contact)
|
||||
end
|
||||
|
||||
def conversation_created(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
execute_account_hooks(event, conversation.account, conversation: conversation)
|
||||
end
|
||||
|
||||
def conversation_resolved(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
# Only trigger for status changes is resolved
|
||||
return unless conversation.status == 'resolved'
|
||||
|
||||
execute_account_hooks(event, conversation.account, conversation: conversation)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def execute_hooks(event, message)
|
||||
@@ -22,4 +45,10 @@ class HookListener < BaseListener
|
||||
HookJob.perform_later(hook, event.name, message: message)
|
||||
end
|
||||
end
|
||||
|
||||
def execute_account_hooks(event, account, event_data = {})
|
||||
account.hooks.account_hooks.find_each do |hook|
|
||||
HookJob.perform_later(hook, event.name, event_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,6 +18,10 @@ class Integrations::App
|
||||
I18n.t("integration_apps.#{params[:i18n_key]}.description")
|
||||
end
|
||||
|
||||
def short_description
|
||||
I18n.t("integration_apps.#{params[:i18n_key]}.short_description")
|
||||
end
|
||||
|
||||
def logo
|
||||
params[:logo]
|
||||
end
|
||||
@@ -51,6 +55,8 @@ class Integrations::App
|
||||
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
|
||||
when 'shopify'
|
||||
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
|
||||
when 'leadsquared'
|
||||
account.feature_enabled?('crm_integration')
|
||||
else
|
||||
true
|
||||
end
|
||||
|
||||
@@ -19,11 +19,13 @@ class Integrations::Hook < ApplicationRecord
|
||||
|
||||
attr_readonly :app_id, :account_id, :inbox_id, :hook_type
|
||||
before_validation :ensure_hook_type
|
||||
after_create :trigger_setup_if_crm
|
||||
|
||||
validates :account_id, presence: true
|
||||
validates :app_id, presence: true
|
||||
validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' }
|
||||
validate :validate_settings_json_schema
|
||||
validate :ensure_feature_enabled
|
||||
validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } }
|
||||
|
||||
# TODO: This seems to be only used for slack at the moment
|
||||
@@ -36,6 +38,9 @@ class Integrations::Hook < ApplicationRecord
|
||||
|
||||
enum hook_type: { account: 0, inbox: 1 }
|
||||
|
||||
scope :account_hooks, -> { where(hook_type: 'account') }
|
||||
scope :inbox_hooks, -> { where(hook_type: 'inbox') }
|
||||
|
||||
def app
|
||||
@app ||= Integrations::App.find(id: app_id)
|
||||
end
|
||||
@@ -61,8 +66,21 @@ class Integrations::Hook < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def feature_allowed?
|
||||
return true if app.blank?
|
||||
|
||||
flag = app.params[:feature_flag]
|
||||
return true unless flag
|
||||
|
||||
account.feature_enabled?(flag)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_feature_enabled
|
||||
errors.add(:feature_flag, 'Feature not enabled') unless feature_allowed?
|
||||
end
|
||||
|
||||
def ensure_hook_type
|
||||
self.hook_type = app.params[:hook_type] if app.present?
|
||||
end
|
||||
@@ -72,4 +90,17 @@ class Integrations::Hook < ApplicationRecord
|
||||
|
||||
errors.add(:settings, ': Invalid settings data') unless JSONSchemer.schema(app.params[:settings_json_schema]).valid?(settings)
|
||||
end
|
||||
|
||||
def trigger_setup_if_crm
|
||||
# we need setup services to create data prerequisite to functioning of the integration
|
||||
# in case of Leadsquared, we need to create a custom activity type for capturing conversations and transcripts
|
||||
# https://apidocs.leadsquared.com/create-new-activity-type-api/
|
||||
return unless crm_integration?
|
||||
|
||||
::Crm::SetupJob.perform_later(id)
|
||||
end
|
||||
|
||||
def crm_integration?
|
||||
%w[leadsquared].include?(app_id)
|
||||
end
|
||||
end
|
||||
|
||||
92
app/services/crm/base_processor_service.rb
Normal file
92
app/services/crm/base_processor_service.rb
Normal 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
|
||||
36
app/services/crm/leadsquared/api/activity_client.rb
Normal file
36
app/services/crm/leadsquared/api/activity_client.rb
Normal 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
|
||||
84
app/services/crm/leadsquared/api/base_client.rb
Normal file
84
app/services/crm/leadsquared/api/base_client.rb
Normal 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
|
||||
50
app/services/crm/leadsquared/api/lead_client.rb
Normal file
50
app/services/crm/leadsquared/api/lead_client.rb
Normal 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
|
||||
59
app/services/crm/leadsquared/lead_finder_service.rb
Normal file
59
app/services/crm/leadsquared/lead_finder_service.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
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?
|
||||
|
||||
search_by_field(contact.phone_number)
|
||||
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
|
||||
35
app/services/crm/leadsquared/mappers/contact_mapper.rb
Normal file
35
app/services/crm/leadsquared/mappers/contact_mapper.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
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' => contact.phone_number.presence,
|
||||
'Source' => brand_name
|
||||
}.compact
|
||||
end
|
||||
|
||||
def brand_name
|
||||
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'] || 'Chatwoot'
|
||||
end
|
||||
|
||||
def brand_name_without_spaces
|
||||
brand_name.gsub(/\s+/, '')
|
||||
end
|
||||
end
|
||||
108
app/services/crm/leadsquared/mappers/conversation_mapper.rb
Normal file
108
app/services/crm/leadsquared/mappers/conversation_mapper.rb
Normal 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(conversation)
|
||||
new(conversation).conversation_activity
|
||||
end
|
||||
|
||||
def self.map_transcript_activity(conversation, messages = nil)
|
||||
new(conversation, messages).transcript_activity
|
||||
end
|
||||
|
||||
def initialize(conversation, messages = nil)
|
||||
@conversation = conversation
|
||||
@messages = messages
|
||||
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, :messages
|
||||
|
||||
def formatted_creation_time
|
||||
conversation.created_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||
end
|
||||
|
||||
def transcript_messages
|
||||
@transcript_messages ||= 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)
|
||||
# TODO: Figure out what timezone to send the time in
|
||||
message.created_at.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
|
||||
121
app/services/crm/leadsquared/processor_service.rb
Normal file
121
app/services/crm/leadsquared/processor_service.rb
Normal 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(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(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
|
||||
108
app/services/crm/leadsquared/setup_service.rb
Normal file
108
app/services/crm/leadsquared/setup_service.rb
Normal file
@@ -0,0 +1,108 @@
|
||||
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']
|
||||
|
||||
endpoint_url = "https://#{endpoint_host}/v2/"
|
||||
app_url = "https://#{app_host}/"
|
||||
|
||||
update_hook_settings({ :endpoint_url => endpoint_url, :app_url => app_url })
|
||||
|
||||
# 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
|
||||
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
|
||||
@@ -1,6 +1,7 @@
|
||||
json.id resource.id
|
||||
json.name resource.name
|
||||
json.description resource.description
|
||||
json.short_description resource.short_description.presence
|
||||
json.enabled resource.enabled?(@current_account)
|
||||
|
||||
if Current.account_user&.administrator?
|
||||
|
||||
Reference in New Issue
Block a user