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? with_stale_lead_recovery(contact, lead_id) { |id| @lead_client.update_lead(lead_data, 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 = with_stale_lead_recovery(conversation.contact, lead_id) do |id| @activity_client.post_activity(id, activity_code, activity_note) end return if activity_id.blank? metadata = {} metadata[metadata_key] = activity_id store_conversation_metadata(conversation, metadata) rescue Crm::Leadsquared::Api::BaseClient::ApiError => e log_activity_error(e, activity_type, conversation, payload: { lead_id: lead_id, activity_code: activity_code, activity_note: activity_note }) rescue StandardError => e log_activity_error(e, activity_type, conversation) end # The cached lead id can become stale when the lead is deleted/merged in LeadSquared, # making LeadSquared reject the call with "Lead not found". When that happens, clear the # stored id, re-resolve the contact to a fresh lead, and run the operation again once. def with_stale_lead_recovery(contact, lead_id) yield(lead_id) rescue Crm::Leadsquared::Api::BaseClient::ApiError => e raise unless lead_not_found_error?(e) Rails.logger.warn("LeadSquared stale lead #{lead_id} for contact ##{contact.id}, clearing and retrying") clear_external_id(contact) fresh_lead_id = get_lead_id(contact) raise if fresh_lead_id.blank? || fresh_lead_id == lead_id yield(fresh_lead_id) end def lead_not_found_error?(error) return false if error.response.blank? parsed = error.response.parsed_response parsed.is_a?(Hash) && parsed['ExceptionType'] == 'MXInvalidEntityReferenceException' rescue StandardError false end def log_activity_error(error, activity_type, conversation, payload: nil) ChatwootExceptionTracker.new(error, account: @account).capture_exception context = "account_id=#{conversation.account_id}, conversation_display_id=#{conversation.display_id}" if payload context += ", http_status=#{error.code}, prospect_id=#{payload[:lead_id]}, " \ "activity_event=#{payload[:activity_code]}, note_bytes=#{payload[:activity_note].to_s.bytesize}" end Rails.logger.error("LeadSquared #{activity_type} activity failed: #{error.message} (#{context})") 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}") return 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