MassProspecting / docs

Public documentation, roadmap and issue tracker of MassProspecting
http://doc.massprospecting.com/
1 stars 0 forks source link

SDK documentation #355

Open leandrosardi opened 4 days ago

leandrosardi commented 4 days ago

Use GPT for writing documentation.

Training Prompt 1

A skeleton class is a class developed in Ruby that inherits from Ruby Sequel and has direct access to the database. Instances of skeleton classes work at the server side.

A stub class is a class with API communication with the server. Instances of stub classes work at the client side. Instances of stub classes have a counter-part at the server side who generate the API responses.

Any stub class inherits directly or indirectly from BlackStack::Base:

module BlackStack
    # Base class.
    # List of methods you have to overload if you develop a profile type.
    # 
    class Base
        # object json descriptor
        attr_accessor :desc

        def self.object_name
            raise 'You have to overload this method in your class.'
        end

        def initialize(h)
            self.desc = h
        end

        # Crate an instance of a child class using speicfications in the `desc['name']` attribute.
        # By default, returns the same instance.
        def child_class_instance
            return self
        end

        # sysowner must provide the id_account for getting an account value.
        # for non sysowner it is assigned to his account.
        def self.account_value(id_account:nil, field:)
            params = {}
            params['id_account'] = id_account
            params['field'] = field
            # call the API
            ret = BlackStack::API.post(
                endpoint: "account_value",
                params: params
            )
            raise "Error calling account_value endpoint: #{ret['status']}" if ret['status'] != 'success'
            return ret['result']
        end # def self.base

        # Get array of hash descriptor of profile.
        # 
        # Parameters: 
        # - id_account: guid. Id of the account to bring the profiles. Sysowner must provide the id_account for getting an account value. For non sysowner it is assigned to his account.
        # - promiscuous: boolean. It works only for Sysowner. If true, it will bring all non-deleted rows, including the ones that are not owned by Sysowner. If false, it will bring only rows matching id_profile. Default: false.
        # - page: integer. Page number.
        # - limit: integer. Number of profiles per page.
        # - params: hash. Additional filter parameters used by the specific child class.
        #
        def self.page(id_account: nil, promiscuous: false, page:, limit:, filters: {})
            # add page and limit to the params
            params = {}
            params['id_account'] = id_account
            params['promiscuous'] = promiscuous
            params['page'] = page
            params['limit'] = limit
            params['filters'] = filters
            params['backtrace'] = BlackStack::API.backtrace
            # call the API
            ret = BlackStack::API.post(
                endpoint: "#{self.object_name}/page",
                params: params
            )
            raise "Error calling page endpoint: #{ret['status']}" if ret['status'] != 'success'
            return ret['results'].map { |h| self.new(h).child_class_instance }
        end # def self.base

        # Get array of hash descriptors of profiles.
        # 
        # Parameters: 
        # - id: guid. Id of the profile to bring.
        #
        def self.count
            params = {}
            params['backtrace'] = BlackStack::API.backtrace
            ret = BlackStack::API.post(
                endpoint: "#{self.object_name}/count",
                params: params
            )
            raise "Error calling count endpoint: #{ret['status']}" if ret['status'] != 'success'
            return ret['result'].to_i
        end # def self.count

        # Get array of hash descriptors of profiles.
        # 
        # Parameters: 
        # - id: guid. Id of the profile to bring.
        #
        def self.get(id)
            params = {}
            params['id'] = id
            params['backtrace'] = BlackStack::API.backtrace
            ret = BlackStack::API.post(
                endpoint: "#{self.object_name}/get",
                params: params
            )
            raise "Error calling get endpoint: #{ret['status']}" if ret['status'] != 'success'
            return self.new(ret['result']).child_class_instance
        end # def self.get

        # Submit a hash descriptor to the server for an update
        #
        def self.update(desc)
            params = {}
            params['desc'] = desc
            params['backtrace'] = BlackStack::API.backtrace
            ret = BlackStack::API.post(
                endpoint: "#{self.object_name}/update",
                params: params
            )
            raise "Error calling update endpoint: #{ret['status']}" if ret['status'] != 'success'
            return self.new(ret['result']).child_class_instance
        end # def self.update

        # Submit a hash descriptor to the server for an update of status only
        #
        def self.update_status(desc)
          params = {}
          params['desc'] = desc
          params['backtrace'] = BlackStack::API.backtrace
          ret = BlackStack::API.post(
              endpoint: "#{self.object_name}/update_status",
              params: params
          )
          raise "Error calling update_status endpoint: #{ret['status']}" if ret['status'] != 'success'
          return
        end # def self.update

        # Submit a hash descriptor to the server for an update
        #
        def update
            self.class.update(self.desc)
        end

        # Submit a hash descriptor to the server for an update of status only
        #
        def update_status
          self.class.update(self.desc)
        end

        # Submit a hash descriptor to the server for an insert
        #
        def self.insert(desc)
            params = {}
            params['desc'] = desc
            params['backtrace'] = BlackStack::API.backtrace
            ret = BlackStack::API.post(
                endpoint: "#{self.object_name}/insert",
                params: params
            )
            raise "Error calling insert endpoint: #{ret['status']}" if ret['status'] != 'success'
            return self.new(ret['result']).child_class_instance
        end # def self.insert

        # Submit a hash descriptor to the server for an upsert
        #
        def self.upsert(desc)
            params = {}
            params['desc'] = desc
            params['backtrace'] = BlackStack::API.backtrace
            ret = BlackStack::API.post(
                endpoint: "#{self.object_name}/upsert",
                params: params
            )
            raise "Error calling upsert endpoint: #{ret['status']}" if ret['status'] != 'success'
            return ret['result'] == {} ? nil : self.new(ret['result']).child_class_instance
        end # def self.upsert

        # Submit a hash descriptor to the server for an upsert
        #
        def upsert
            self.class.upsert(self.desc)
        end

        # return the HTML of a page downloaded by Zyte.
        #
        # Parameters:
        # - url: the URL of the page to download.
        # - api_key: the Zyte API key.
        # - options: the options to pass to Zyte.
        #
        def zyte_html(url, api_key:, options:, data_filename:)
            ret = nil
            # getting the HTML
            zyte = ZyteClient.new(key: api_key)
            html = zyte.extract(url: url, options: options, data_filename: data_filename) 
            # return the URL of the file in the cloud
            return html
        end # def zyte_html

        # create a file in the cloud with the HTML of a page downloaded by Zyte.
        # return the URL of the file.
        #
        # Parameters:
        # - url: the URL of the page to download.
        # - api_key: the Zyte API key.
        # - options: the options to pass to Zyte.
        # - dropbox_folder: the folder in the cloud where to store the file. If nil, it will use the self.desc['id_account'] value.
        # - retry_times: the number of times to retry the download until the HTML is valid.
        #
        def zyte_snapshot(url, api_key:, options:, data_filename:, dropbox_folder:nil, retry_times: 3)
            # "The garbage character must be due to the 520 error code which was caused on the second request."
            garbage = "\x9E\xE9e"

            ret = nil
            raise "Either dropbox_folder parameter or self.desc['id_account'] are required." if dropbox_folder.nil? && self.desc['id_account'].nil?
            dropbox_folder = self.desc['id_account'] if dropbox_folder.nil?
            # build path to the local file in /tmp
            id = SecureRandom.uuid
            filename = "#{id}.html"
            tmp_path = "/tmp/#{filename}"
            # build path to the file in the cloud
            year = Time.now.year.to_s.rjust(4,'0')
            month = Time.now.month.to_s.rjust(2,'0')
            dropbox_folder = "/massprospecting.bots/#{dropbox_folder}.#{year}.#{month}"
            dropbox_path = "#{dropbox_folder}/#{filename}"
            # getting the HTML - Retry mechanism
            zyte = ZyteClient.new(key: api_key)
            try = 0
            html = garbage
            while try < retry_times && html == garbage
                html = zyte.extract(url: url, options: options, data_filename: data_filename) 
                try += 1
            end
            # save the HTML in the local file in /tmp
            File.open(tmp_path, 'w') { |file| file.write(html) }
            # create the folder in the cloud and upload the file
            BlackStack::DropBox.dropbox_create_folder(dropbox_folder)
            BlackStack::DropBox.dropbox_upload_file(tmp_path, dropbox_path)
            # delete the local file
            File.delete(tmp_path)
            # return the URL of the file in the cloud
            return BlackStack::DropBox.get_file_url(dropbox_path)
        end # def zyte_snapshot

    end # class Base
module BlackStack

Skeleton classes use to extend these Ruby modules:

# MassProspecting Configurations
module Mass
    # default domain for tracking 
    @@tracking_url = nil

    # merge-tags
    UNSUBSCRIBE_MERGETAG = '{unsubscribe-url}'
    @@mergetags = [
        '{company-name}',
        #'{company-location}',
        #'{company-industry}',
        #'{company-email}',
        #'{company-phone}',
        #'{company-linkedin}',
        #'{company-facebook}',
        '{first-name}',
        '{last-name}',
        '{job-title}',
        '{location}',
        '{email}',
        '{phone}',
        '{linkedin}',
        '{facebook}',
        UNSUBSCRIBE_MERGETAG,
        '{unsubscribe-link}',
    ]

    def self.set_mergetags(tags)
        @@mergetags = tags
    end

    def self.mergetags
        @@mergetags
    end

    # MassProspecting API
    def self.set(
        tracking_url:'http://127.0.0.1:3000'
    )

        @@tracking_url = tracking_url
    end

    def self.tracking_url
        @@tracking_url
    end

    module Constants
        URL_PATTERN = /^http(s)?:\/\/[a-z0-9\-\._~:\/\?#\[\]@!\$&'\(\)\*\+,;=]+$/i
        MAX_COLOR_VALUE = 16777215
        MAX_INT = 2147483647
        MAX_PORT_NUMBER = 65535
        MAX_BIGINT = 36550000000000
    end # module Constants

    module State
        # return an array with the different type of states
        def states
            [:idle, :online, :starting, :stopping, :scraping_inbox, :scraping_connections, :scraping, :enrichment, :outreach]
        end # def self.states

        # return an array with the color of differy type of states
        def state_colors
            [:black, :green, :cyan, :pink, :orange, :blue, :orange, :orange, :orange]
        end # def self.state_colors

        # return position of the array self.states, with the key.to_sym value
        def state_code(key)
            self.states.index(key.to_sym)
        end # def self.state_code
    end # module State

    module VerificationResult
        def self.results()
            [
                :any,
                :pending, # this value means that the verification has not been processed yet.
                :valid,
                :invalid,
                :catchall,
                :role,
                :unknown,
                :disabled, 
                :disposable, 
                :inbox_full,
                :role_account,
                :spamtrap,
            ]
        end 

        def results
            Mass::VerificationResult.results
        end

        def result_code(key)
            self.results.index(key.to_sym)
        end

        # validate the values of some specific keys are valid tristates.
        def verification_email_result_errors(h={}, keys:)
            ret = []
            keys.each do |k|
                ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} must be a valid tristate (#{Mass::VerificationResult.results.join(",")})." if h[k.to_s] && Mass::VerificationResult.results.index(h[k.to_s].to_sym).nil?
            end # keys.each
            return ret
        end # def linkedin_errors

    end # module VerificationResult

    module Access
        # return an array with the different type of accesses
        def accesses
            [:rpa, :api, :mta, :basic]
        end # def self.accesses

        # return an array with the different type of accesses
        def access_colors
            [:cyan, :orange, :purple, :gray]
        end # def self.accesses

        # return position of the array self.accesses, with the key.to_sym value
        def access_code(key)
            self.accesses.index(key.to_sym)
        end # def self.access
    end # module Access

    module Direction
        # return an array with the different type of directions
        def directions
            [:outgoing, :incoming, :accepted]
        end # def self.accesses

        # return position of the array self.states, with the key.to_sym value
        def direction_code(key)
            self.directions.index(key.to_sym)
        end # def self.access
    end # module Direction

    module MTA
        def authentications
            [:plain, :login, :cram_md5, :none]
        end

        def authentication_code(key)
            self.authentications.index(key.to_sym)
        end

        # OpenSSL::SSL::VERIFY_NONE == 0
        # OpenSSL::SSL::VERIFY_PEER == 1
        # OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT == 2
        # OpenSSL::SSL::VERIFY_CLIENT_ONCE == 4
        def openssl_verify_modes
            [:none, :peer, :fail_if_no_peer_cert, nil, :client_once]
        end

        def openssl_verify_mode_code(key)
            self.openssl_verify_modes.index(key.to_sym) if key
        end

    end 

    module Body
        # return an array with the different type of bodies
        def bodies
            [:plain, :html]
        end # def self.bodies

        # return position of the array self.accesses, with the key.to_sym value
        def body_code(key)
            self.bodies.index(key.to_sym)
        end # def self.body_code
    end # module Body

    module Type
        # return an array with the different type of bodies
        def types
            [
                :string, 
                :integer, 
                :float, 
                :boolean, 
                :date, 
                :time, 
                :text
            ]
        end # def self.types

        # return position of the array self.accesses, with the key.to_sym value
        def type_code(key)
            self.types.index(key.to_sym)
        end # def self.type_code
    end # module Type

    module Triggerable
        # return an array with the different type of triggers
        def triggers
            [
                :rule_performed,
                :event_created, 
                :tag_added, 
                :enrichment_done, 
                :outreach_done, # outgoing messages only
                :accepted_request, 
                :incoming_message,                
                :open,
                :click,
            ]
        end # def self.triggers

        # return position of the array self.triggers, with the key.to_sym value
        def trigger_code(key)
            self.triggers.index(key.to_sym)
        end # def self.triggers
    end # module Triggerable

    module Parameters
        # return an array with the different type of parameters (aka: on-the-fly filters) 
        # that can be supported by an outreach or enrichment either.
        def parameters
            [
                'email_verification_result',
                'email',
                'phone',
                'linkedin',
                'facebook',
                'english_names_only',
                'keyword',
                'industry',
                'headcount',
                'revenue',
                'country',
                'sic',
                'naics',
                'location',
            ]
        end # def self.filters
    end

    module Filterable
        # return an array with the different type of filters
        def filters
            [
                :lead, 
                :company, 
                :event, 
                :any
            ]
        end # def self.filters

        # return position of the array self.triggers, with the key.to_sym value
        def filter_code(key)
            self.filters.index(key.to_sym)
        end # def self.filter_code
    end # module Filterable

    module Actionable
        # return an array with the different type of actions
        def actions
            [
                :new_enrichment, 
                :new_outreach, 
                :add_tag, 
                :remove_tag,
                :ai,
            ]
        end # def self.actions

        # return position of the array self.actions, with the key.to_sym value
        def action_code(key)
            self.actions.index(key.to_sym)
        end # def self.actions
    end # module Actionable

    module Unit
        # return an array with the different type of states
        def units
            [:minutes, :hours, :days, :weeks, :months]
        end # def self.units

        # return position of the array self.states, with the key.to_sym value
        def unit_code(key)
            self.units.index(key.to_sym)
        end # def self.unit_code
    end # module Unit

    module Skip
        # return an array with the different type of states
        def skips
            [:global, :tag_only, :none]
        end # def self.units

        # return position of the array self.states, with the key.to_sym value
        def skip_code(key)
            self.skips.index(key.to_sym)
        end # def self.unit_code
    end # module Skip

    module TimelinePendings
        # Return up to `limit` objects `Mass::Job` with `job.creation_tracking_time` is `null`.
        def pending_summarize_creation_to_timeline(limit:100)
            return self.where(
                Sequel.lit("
                    summarize_creation_to_timeline IS NULL
                ")
            ).order(:create_time).limit(limit).all
        end

        # Return up to `limit` objects `Mass::Job` with `job.status_tracking_time` is `null` and `job.done_time` is not `null`.
        def pending_summarize_status_to_timeline(limit:100)
            return self.where(
                Sequel.lit("
                    summarize_status_to_timeline IS NULL AND
                    done_time IS NOT NULL AND
                    \"status\" IN (
                        #{Mass::Outreach.status_code(:performed)},
                        #{Mass::Outreach.status_code(:failed)},
                        #{Mass::Outreach.status_code(:aborted)}
                    )
                ")
            ).order(:done_time).limit(limit).all
        end
    end # module TimelinePendings

end # module Mass

module BlackStack
    module InsertUpdate
        # return a Sequel dataset, based on some filters.
        # this method is used by the API to get the data from the database remotely
        def base_list(account=nil, filters: {})
            ds = nil
            if account
                ds = self.where(:id_account => account.id, :delete_time => nil)
            else
                ds = self.where(:delete_time => nil)
            end
            return ds
        end

        # return a Sequel dataset, based on some filters and some pagination parameters.
        # this method is used by the API to get the data from the database remotely
        def page(account=nil, page:, limit:, filters: {})
            ds = self.list(account, filters: filters)
            ds = ds.limit(limit).offset((page-1)*limit).order(:create_time)
            return ds
        end

        # insert a record
        # 
        # parameters:
        # - upsert_children: call upsert on children objects, or call insert on children objects
        # 
        # return the object
        def insert(h={}, upsert_children: true)
            h = self.normalize(h)
            errors = self.errors(h)
            raise errors.join("\n") if errors.size > 0

            b = upsert_children
            o = self.new
            o.id = h['id'] || guid
            o.id_account = h['id_account']
            o.id_user = h['id_user']
            o.create_time = now
            o.update(h, upsert_children: b)
            o.save
            return o
        end

        # insert or update a record
        # 
        # parameters:
        # - upsert_children: call upsert on children objects, or call insert on children objects
        # 
        # return the object
        def upsert(h={}, upsert_children: true)
            o = self.find(h)
            b = upsert_children
            if o.nil?
                o = self.insert(h, upsert_children: b)
            else
                o.update(h, upsert_children: b)
            end

            return o
        end # def self.upsert

        # insert or update an array of hash descriptors
        def insert_update_many(a, logger: nil)
            l = logger || BlackStack::DummyLogger.new
            i = 0
            a.each do |h|
                i += 1
                l.logs "Processing record #{i}/#{a.size}... " 
                self.upsert(h)
                l.logf 'done'.green
            end
        end # def insert_update_many
    end

    module Serialize
        # update a record
        # 
        # parameters:
        # - upsert_children: call upsert on children objects, or call insert on children objects.
        # - manage_done_time: if this is moving fom the status :pending to another status at first time, set the done_time.
        # 
        # return the object
        def base_update(h={}, upsert_children: true, manage_done_time: false)
            h = self.class.normalize(h)
            errors = self.class.errors(h)
            raise errors.join("\n") if errors.size > 0

            # if this is moving fom the status :pending to another status at first time, set the done_time
            if manage_done_time
                if self.status == self.class.status_code(:pending) && !h['status'].to_s.empty? && h['status'].to_sym != :pending && self.done_time.nil? 
                    self.done_time = now
                end
            end

            return self
        end

        # call the method upsert for each element of the array a.
        def to_h_base
            {
                'id' => self.id,
                'id_account' => self.id_account,
                'id_user' => self.id_user,
                'create_time' => self.create_time,
                'update_time' => self.update_time,
                'delete_time' => self.delete_time,
            }
        end # def to_h

        def save
            self.update_time = now
            super
        end
    end

    module Palette
        # parameter rgb is an array of 3 integers, each one between 0 and 255.
        # return an integer with the RGB value.
        def rgb_to_int(rgb)
            ret = 0
            ret += rgb[0] << 16
            ret += rgb[1] << 8
            ret += rgb[2]
            return ret
        end # def rgb_to_int

        # parameter rgb is an array of 3 integers, each one between 0 and 255.
        # return the HEX for the RGB value.
        def rgb_to_hex(rgb)
            ret = "#"
            rgb.each do |c|
                ret += c.to_s(16).rjust(2, '0')
            end
            return ret
        end # def rgb_to_hex

        # parameter n is an integer between 0 and 16777215.
        # return an array of 3 integers, each one between 0 and 255.
        def int_to_rgb(n)
            ret = []
            ret[0] = (n >> 16) & 0xFF
            ret[1] = (n >> 8) & 0xFF
            ret[2] = n & 0xFF
            return ret
        end # def int_to_rgb

        # parameter n is an integer between 0 and 16777215.
        # return the HEX for the RGB value.
        def int_to_hex(n)
            rgb = self.int_to_rgb(n)
            return self.rgb_to_hex(rgb)
        end

        # Return a hash with 25 RGB values
        # Each color must support black text inside. 
        # So, avoid dark colors.
        def pallette
            {
                :red => [255, 0, 0],
                :green => [0, 255, 0],
                :blue => [0, 0, 255],
                :yellow => [255, 255, 0],
                :orange => [255, 165, 0],
                :cyan => [0, 255, 255],
                :magenta => [255, 0, 255],
                :white => [255, 255, 255],
                :gray => [128, 128, 128],
                :maroon => [128, 0, 0],
                :olive => [128, 128, 0],
                :navy => [0, 0, 128],
                :purple => [128, 0, 128],
                :teal => [0, 128, 128],
                :light_blue => [173, 216, 230],
                :light_green => [144, 238, 144],
                :light_red => [255, 182, 193],
                :black => [0, 0, 0],            
            }
        end # def pallette

        # parameter key is a symbol
        # return the HEX for the RGB value defined in the palette.
        def pallette_to_hex(key)
            rgb = self.pallette[key]
            return self.rgb_to_hex(rgb)
        end # def pallette_to_hex

        # parameter key is a symbol
        # return the RGB value defined in the palette.
        def pallette_to_rgb(key)
            return self.pallette[key]
        end # def pallette_to_rgb

        # parameter key is a symbol
        # return the RGB value defined in the palette.
        def pallette_to_int(key)
            rgb = self.pallette[key]
            return self.rgb_to_int(rgb)
        end # def pallette_to_int
    end # module Palette

    module Storage
        # Download a file form the web and store it in Dropbox.
        # Return the URL of the file in Dropbox.
        #
        # Parameters:
        # - url: the URL of the file to download.
        # - filename: the name of the file to store in Dropbox.
        # - dropbox_folder: the name of the folder in Dropbox where the file will be stored. E.g.: "channels/#{account.id}".
        # - downloadeable: if true, the returnled URL will allow the download of the file. If false, the URL will show the file in the browser.
        # 
        def store(url:, filename:, dropbox_folder:, downloadeable: false)
            tempfile = nil
            if url =~ /^http(s)?\:\/\//
                tempfile = Down.download(url)
            elsif url =~ /^file\:\/\//
                tempfile = File.open(url.gsub(/^file\:\/\//, '/'))
            end
            year = Time.now.year.to_s.rjust(4,'0')
            month = Time.now.month.to_s.rjust(2,'0')
            folder = "/massprospecting.bots/#{dropbox_folder}.#{year}.#{month}"
            path = "#{folder}/#{filename}"
            BlackStack::DropBox.dropbox_create_folder(folder)
            BlackStack::DropBox.dropbox_upload_file(tempfile.path, path)
            ret = BlackStack::DropBox.get_file_url(path)
            ret.gsub!(/\&dl\=1/, "&dl=0") if downloadeable == false
            # delete the temporary file if I downloaded it form the web
            if url =~ /^http(s)?\:\/\//
                # delete the temporary file from the hard drive
                File.delete(tempfile.path) if File.exists?(tempfile.path)
            end
            # return the URL of the file in Dropbox.
            return ret
        end
    end # module Storage

    module Status
        # return an array with the different type of states
        # - :pending - it is waiting to be executed
        # - :running - it is being executed right now
        # - :performed - it was executed successfully
        # - :failed - it was executed with errors
        # - :aborted - it started but it was aborted because filters didn't pass
        # - :canceled - it was canceled by the user
        # 
        def statuses
            [:pending, :running, :performed, :failed, :aborted, :canceled]
        end # def self.accesses

        # return an array with the color of differy type of status
        def status_colors
            [:gray, :blue, :green, :red, :black, :black]
        end # def self.state_colors

        # return position of the array self.states, with the key.to_sym value
        def status_code(key)
            self.statuses.index(key.to_sym)
        end # def self.access
    end # module Status

    module DomainProtocol
        # return an array with the different type of domain_protocols
        # 
        def domain_protocols
            [:http, :https]
        end # def self.domain_protocols

        # return position of the array self.domain_protocols, with the key.to_sym value
        def domain_protocols_code(key)
            self.domain_protocols.index(key.to_sym)
        end # def self.domain_protocols_code
    end # module DomainProtocol

    module Validation
        INDEED_COMPANY_PATTERN = /^https\:\/\/(www\.)?indeed\.com\/cmp\//

        # normalize the values of the descriptor.
        def normalize(h={})
            h['id_account'] = h['id_account'].to_s.strip.downcase if h['id_account']
            h['id_user'] = h['id_user'].to_s.strip.downcase if h['id_user']
            return h
        end # def self.normalize

        # return an array of error messages if there are keys now allowed
        def key_errors(h={}, allowed_keys:)
            allowed_keys += [:id_account, :id_user, :id, :create_time, :update_time, :delete_time] 
            ret = []
            h.keys.each do |k|
                ret << "The key '#{k}' is not allowed in #{self.name.gsub('Mass::', '')}." if !allowed_keys.map { |s| s.to_s }.include?(k.to_s)
            end
            return ret
        end # def key_errors

        # validate some keys of the hash descriptor are present
        def mandatory_errors(h={}, keys:)
            ret = []
            keys.each do |k|
                ret << "The #{k} is required for #{self.name.gsub('Mass::', '')}." if h[k.to_s].nil? || h[k.to_s].to_s.empty?
            end
            return ret
        end # def mandatory_errors

        # validate the values of some specific keys are valid URLs.
        def url_errors(h={}, keys:)
            ret = []
            keys.each do |k|
                unless h[k.to_s].nil? || h[k.to_s].to_s.empty?
                    # validate the URL is reachable
                    if h[k.to_s].to_s.valid_url?
                        begin
                            uri = URI.parse(h[k.to_s])
                            response = Net::HTTP.get_response(uri)
                            ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} is not reachable." if response.code.to_i != 200
                        rescue
                            ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} is not reachable."
                        end
                    elsif h[k.to_s].to_s.valid_filename?
                        ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} is not reachable." if !File.exists?(h[k.to_s].gsub(/^file\:\/\//, '/'))
                    else
                        ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} is not a valid URL or File."
                    end
                end
            end # keys.each
            return ret
        end # def url_errors

        # validate the values of some specific keys are valid Emails.
        def email_errors(h={}, keys:)
            ret = []
            keys.each do |k|
                ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} must be a valid Email." if h[k.to_s] && !h[k.to_s].to_s.match(/^[\w\.\-]+@[\w\.\-]+\.\w+$/)
            end # keys.each
            return ret
        end # def email_errors

        def normalize_linkedin_url(url)
            # remove query string parameters
            url = url.gsub(/\?.*$/, '')
            # remove # at the end
            url = url.gsub(/\#.*$/, '')
            # add 'http' at the begining if it doesn't exists
            url = "http://#{url}" if !url.match(/^http(s)?\:\//)
            # add 'www.' at the begining if it doesn't exists
            url = url.gsub(/^http(s)?\:\/\/(www\.)?/, 'https://www.')
            # remove the last '/' if it exists
            url = url.gsub(/\/$/, '')
            return url.to_s.downcase
        end

        # validate the values of some specific keys are valid Emails.
        def linkedin_errors(h={}, keys:)
            ret = []
            keys.each do |k|
                url = h[k.to_s].nil? ? nil : self.normalize_linkedin_url(h[k.to_s])
                ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} must be a valid LinkedIn URL." if url && !url.to_s.match(/^http(s)?\:\/\/(www\.)?linkedin\.com\/in\/[^\/]+$/i)
            end # keys.each
            return ret
        end # def linkedin_errors

        def normalize_linkedin_company_url(url)
            # remove query string parameters
            url = url.gsub(/\?.*$/, '')
            # remove # at the end
            url = url.gsub(/\#.*$/, '')
            # add 'http' at the begining if it doesn't exists
            url = "http://#{url}" if !url.match(/^http(s)?\:\//)
            # add 'www.' at the begining if it doesn't exists
            url = url.gsub(/^http(s)?\:\/\/(www\.)?/, 'https://www.')
            # remove the last '/' if it exists
            url = url.gsub(/\/$/, '')
            return url.to_s.downcase
        end

        # validate the values of some specific keys are valid Emails.
        def linkedin_company_errors(h={}, keys:)
            ret = []
            keys.each do |k|
                url = h[k.to_s].nil? ? nil : self.normalize_linkedin_company_url(h[k.to_s])
                ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} must be a valid LinkedIn URL." if url && !url.to_s.match(/^http(s)?\:\/\/(www\.)?linkedin\.com\/company\/[^\/]+$/i)
            end # keys.each
            return ret
        end # def linkedin_errors

        def normalize_indeed_company_url(url)
            url.split('?').first
        end

        # validate the values of some specific keys are valid Emails.
        def indeed_company_errors(h={}, keys:)
            ret = []
            keys.each do |k|
                url = h[k.to_s].nil? ? nil : self.normalize_indeed_company_url(h[k.to_s])
                ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} must be a valid Indeed URL." if url && url.to_s !~ INDEED_COMPANY_PATTERN
            end # keys.each
            return ret
        end # def linkedin_errors

        # validate the values of some specific keys are valid tristates.
        def tristate_errors(h={}, keys:)
            ret = []
            keys.each do |k|
                ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} must be a valid tristate (#{BlackStack::Tristate.tristates.join(",")})." if h[k.to_s] && BlackStack::Tristate.tristates.index(h[k.to_s].to_sym).nil?
            end # keys.each
            return ret
        end # def linkedin_errors

        def normalize_facebook_url(url)
            # remove query string parameters
            url = url.gsub(/\?.*$/, '')
            # remove # at the end
            url = url.gsub(/\#.*$/, '')
            # add 'http' at the begining if it doesn't exists
            url = "http://#{url}" if !url.match(/^http(s)?\:\//)
            # remove 'www.'
            url = url.gsub(/^http(s)?\:\/\/(www\.)?/, 'https://')
            # add 'www.' at the begining if it doesn't exist
            url = url.gsub(/^http(s)?\:\/\/(web\.)?/, 'https://web.')
            # remove /refname/
            url = url.gsub(/\/refname\//, '/')
            # remove the last '/' if it exists
            url = url.gsub(/\/$/, '')
            return url.to_s.downcase
        end

        # validate the values of some specific keys are valid Emails.
        def facebook_errors(h={}, keys:)
            ret = []
            keys.each do |k|
                url = h[k.to_s].nil? ? nil : self.normalize_facebook_url(h[k.to_s])
                ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} must be a valid Facebook URL." if url && !url.to_s.match(/^http(s)?\:\/\/(web\.)?facebook\.com\/[a-z0-9\-\._]+$/i)
            end # keys.each
            return ret
        end # def linkedin_errors

        def normalize_facebook_company_url(url)
            normalize_facebook_url(url)
        end

        # validate the values of some specific keys are valid Emails.
        def facebook_company_errors(h={}, keys:)
            facebook_errors(h, keys: keys)
        end # def linkedin_errors

        # get domain from url using URI
        def normalize_email(email)
            email.to_s.strip.downcase
        end

        # get domain from url using URI
        def normalize_domain(url)
            url = url.to_s.strip.downcase
            # remove query string parameters
            url = url.gsub(/\?.*$/, '')
            # remove # at the end
            url = url.gsub(/\#.*$/, '')
            # add 'http' at the begining if it doesn't exists
            url = "http://#{url}" if !url.match(/^http(s)?\:\//)
            # remove 'www.'
            url = url.gsub(/^http(s)?\:\/\/(www\.)?/, '')
            # add 'www.' at the begining if it doesn't exist
            url = url.gsub(/^http(s)?\:\/\/(web\.)?/, '')
            # remove the last '/' if it exists
            url = url.gsub(/\/$/, '')
            # remove protoco
            url = url.gsub(/^http\:\/\//, '')
            url = url.gsub(/^https\:\/\//, '')
            return url.to_s.downcase
        end

        # validate the values of some specific keys are valid strings
        def string_errors(h={}, keys:)
            ret = []
            keys.each do |k|
                ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} must be a string." if h[k.to_s] && !h[k.to_s].is_a?(String)
            end
            return ret
        end # def string_errors

        # validate the values of some specific keys are valid domains
        def domain_errors(h={}, keys:)
            ret = []
            keys.each do |k|
                ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} must be a valid domain." if h[k.to_s] && !h[k.to_s].to_s.strip.downcase.valid_domain?
            end
            return ret
        end # def domain_errors

        # validate the values of some specific keys are valid integers, between 2 values.
        def int_errors(h={}, min:, max:, keys:)
            ret = []
            keys.each do |k|
                ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} must be an integer." if h[k.to_s] && !h[k.to_s].is_a?(Integer)
                ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} must be greater than #{min}." if h[k.to_s] && h[k.to_s].is_a?(Integer) && h[k.to_s] < min
                ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} must be less than #{max}." if h[k.to_s] && h[k.to_s].is_a?(Integer) && h[k.to_s] > max
            end
            return ret
        end # def int_errors

        # validate the values of some specific keys are valid floats, between 2 values.
        def float_errors(h={}, min:, max:, keys:)
            ret = []
            keys.each do |k|
                ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} must be a float." if h[k.to_s] && !h[k.to_s].is_a?(Float)
                ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} must be greater than #{min}." if h[k.to_s] && h[k.to_s].is_a?(Float) && h[k.to_s] < min
                ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} must be less than #{max}." if h[k.to_s] && h[k.to_s].is_a?(Float) && h[k.to_s] > max
            end
            return ret
        end # def float_errors

        # validate the values of some specific keys are valid booleans.
        def boolean_errors(h={}, keys:)
            ret = []
            keys.each do |k|
                ret << "The #{k} '#{h[k.to_s].to_s}' for #{self.name.gsub('Mass::', '')} must be a boolean." if h[k.to_s] && ![true, false].include?(h[k.to_s])
            end
            return ret
        end # def boolean_errors

        # return an array of error messages regarding the ownership of the object.
        def ownership_errors(h={})
            ret = []

            ret << "The id_account for #{self.name.gsub('Mass::', '')} is required." if h['id_account'].nil? || h['id_account'].to_s.empty?
            ret << "The id_account for #{self.name.gsub('Mass::', '')} must be a guid." if h['id_account'] && !h['id_account'].to_s.guid?
            ret << "The id_account for #{self.name.gsub('Mass::', '')} must exist." if h['id_account'] && h['id_account'].to_s.guid? && BlackStack::MySaaS::Account.where(:id => h['id_account']).first.nil?

            ret << "The id_user for #{self.name.gsub('Mass::', '')} must be a guid." if h['id_user'] && !h['id_user'].to_s.guid?
            ret << "The id_user for #{self.name.gsub('Mass::', '')} must exist." if h['id_user'] && h['id_user'].to_s.guid? && BlackStack::MySaaS::User.where(:id => h['id_user']).first.nil?

            if h['id_account'] && h['id_user']
                # user must be belonging the account
                ret << "The user for #{self.name.gsub('Mass::', '')} must belong to the account." if BlackStack::MySaaS::User.where(:id => h['id_user'], :id_account => h['id_account']).first.nil?
            end

            return ret
        end # def ownership_errors

        # return an array of error messages regarding the naming of the object.
        # validations:
        # - name is required.
        # - the name must be unique.
        # - the name must be a string.
        # - the name must be less than 255 characters.
        # 
        def naming_errors(h={})
            h = self.normalize(h)
            ret = []
            ret << "The name for #{self.name.gsub('Mass::', '')} is required." if h['name'].nil? || h['name'].to_s.empty?
            ret << "The name for #{self.name.gsub('Mass::', '')} must be a string." if h['name'] && !h['name'].is_a?(String)
            ret << "The name for #{self.name.gsub('Mass::', '')} must be less than 255 characters." if h['name'] && h['name'].is_a?(String) && h['name'].to_s.length > 255
=begin
            # name must be unique
            ret << "Name is unique and already exists a #{self.name.gsub('Mass::', '')} with the same name." if h['name'] && h['id'].nil? && self.where(
                :id_account => h['id_account'], 
                :name => h['name'].to_s
            ).first
            ret << "Name is unique and already exists a #{self.name.gsub('Mass::', '')} with the same name." if h['name'] && h['id'] && self.where(Sequel.lit("
                id_account = '#{h['id_account']}' AND
                name = '#{h['name'].to_s.to_sql}' AND
                id != '#{h['id']}'
            ")).first
=end
            return ret
        end # def naming_errors

        # return an array of error messages regarding the color of the object.
        def color_errors(h={})
            ret = []
            ret << "The color_code for #{self.name.gsub('Mass::', '')} must be a symbol or string." if h['color_code'] && !h['color_code'].is_a?(Symbol) && !h['color_code'].is_a?(String)
            ret << "The color_code for #{self.name.gsub('Mass::', '')} must be a valid key from the palette." if h['color_code'] && h['color_code'].is_a?(Symbol) && !pallette.keys.include?(h['color_code'].to_sym)
            return ret
        end # def color_errors

    end # module Validation

    module Tristate
        def self.tristates
            [:any, :yes, :no]
        end

        def tristates
            BlackStack::Tristate.tristates
        end

        def tristate_code(key)
            self.tristates.index(key.to_sym)
        end
    end

end ## module BlackStack

Training Prompt 2

The communication between a stub instances and its counter-part skeleton instance is trough the `Mass::Client** library.

mass-client.rb:

require 'uri'
require 'net/http'
require 'json'
require 'blackstack-core'
require 'simple_cloud_logging'
require 'simple_command_line_parser'
require 'colorize'
require 'timeout'
require 'base64'
require 'adspower-client'
require 'aws-sdk-s3' # Ensure the AWS SDK for S3 is installed

# mass client configuration
module Mass
    @@js_path
    @@drownload_path
    @@api_client

    @@s3_bucket
    @@s3_region
    @@s3_access_key_id
    @@s3_secret_access_key
    @@s3

    # set the MassProspecting API client
    #
    # Parameters:
    #
    # api_key: Mandatory. The API key of your MassProspecting account.
    # subaccount: Optional. The name of the subaccount you want to work with. If you provide a subaccount, the SDK will call the master to get the URL and port of the subaccount. Default is nil.
    # 
    # api_url: Optional. The URL of the MassProspecting API. Default is 'https://massprospecting.com'.
    # api_port: Optional. The port of the MassProspecting API. Default is 443.
    # api_version: Optional. The version of the MassProspecting API. Default is '1.0'.
    #
    # backtrace: Optional. If true, the backtrace of the exceptions will be returned by the access points. If false, only an error description is returned. Default is false.
    # 
    # js_path: Optional. The path to the JavaScript file to be used by the SDK. Default is nil.
    # download_path: Optional. The path to the download folder(s) to be used by the SDK. Default is [].
    #
    # s3_region, s3_access_key_id, s3_secret_access_key, s3_bucket: Defining AWS S3 parameter for storing files at the client-side.
    #
    def self.set(
        api_key: ,
        subaccount: nil,

        api_url: 'https://massprospecting.com', 
        api_port: 443,
        api_version: '1.0',

        backtrace: false,

        js_path: nil, 
        download_path: [],

        s3_region: nil,
        s3_access_key_id: nil,
        s3_secret_access_key: nil,
        s3_bucket: nil
    )
        # call the master to get the URL and port of the subaccount.
        BlackStack::API.set_client(
            api_key: api_key,
            api_url: api_url,
            api_port: api_port,
            api_version: api_version,
            backtrace: backtrace
        )

        if subaccount
            params = { 'name' => subaccount }
            ret = BlackStack::API.post(
                endpoint: "resolve/get",
                params: params
            )

            raise "Error initializing client: #{ret['status']}" if ret['status'] != 'success'

            # call the master to get the URL and port of the subaccount.
            BlackStack::API.set_client(
                api_key: ret['api_key'],
                api_url: ret['url'],
                api_port: ret['port'],
                api_version: api_version,
                backtrace: backtrace
            )
        end

        # validate: download_path must be a string or an arrow of strings
        if download_path.is_a?(String)
            raise ArgumentError.new("The parameter 'download_path' must be a string or an array of strings.") if download_path.to_s.empty?
        elsif download_path.is_a?(Array)
            download_path.each { |p|
                raise ArgumentError.new("The parameter 'download_path' must be a string or an array of strings.") if p.to_s.empty?
            }
        else
            raise ArgumentError.new("The parameter 'download_path' must be a string or an array of strings.")
        end

        @@js_path = js_path
        @@download_path = download_path

        @@s3_region = s3_region
        @@s3_access_key_id = s3_access_key_id
        @@s3_secret_access_key = s3_secret_access_key
        @@s3_bucket = s3_bucket

        # Initialize the S3 client
        if (
            @@s3_region
            @@s3_access_key_id
            @@s3_secret_access_key
            @@s3_bucket
        )
            @@s3 = Aws::S3::Client.new(
                region: @@s3_region,
                access_key_id: @@s3_access_key_id,
                secret_access_key: @@s3_secret_access_key
            )
        end
    end

    def self.download_path
        @@download_path
    end

    def self.js_path
        @@js_path
    end

    def self.s3_region
        @@s3_region
    end

    def self.s3_access_key_id
        @@s3_access_key_id
    end

    def self.s3_secret_access_key
        @@s3_secret_access_key
    end

    def self.s3_bucket
        @@s3_bucket
    end

    def self.s3
        @@s3
    end

end # module Mass

# base classes
require_relative './/base-line/channel'

require_relative './/base-line/profile_type'
require_relative './/base-line/source_type'
require_relative './/base-line/enrichment_type'
require_relative './/base-line/outreach_type'
require_relative './/base-line/data_type'

require_relative './/base-line/headcount'
require_relative './/base-line/industry'
require_relative './/base-line/location'
require_relative './/base-line/revenue'
require_relative './/base-line/tag'
require_relative './/base-line/profile'

require_relative './/base-line/source'
require_relative './/base-line/job'
require_relative './/base-line/event'

require_relative './/base-line/outreach'
require_relative './/base-line/enrichment'

require_relative './/base-line/unsubscribe'

require_relative './/base-line/company'
#require_relative './/base-line/company_data'
#require_relative './/base-line/company_industry'
#require_relative './/base-line/company_naics'
#require_relative './/base-line/company_sic'
#require_relative './/base-line/company_tag'

require_relative './/base-line/lead'
#require_relative './/base-line/lead_data'
#require_relative './/base-line/lead_tag'

require_relative './/base-line/inboxcheck'
require_relative './/base-line/connectioncheck'
require_relative './/base-line/rule'
require_relative './/base-line/request'

# first line of children
require_relative './/first-line/profile_api'
require_relative './/first-line/profile_mta'
require_relative './/first-line/profile_rpa'

Training Prompt 3

I am writing a copilot to operate my SaaS:

mass-client.rb:

require 'mass-client' #, '~>1.0.28'
require 'openai' #, '~>6.3.1'
require 'pry' #, '~>0.14.1'

module Mass
    class Copilot
        @@openai_api_key = nil
        @@openai_model = nil
        @@openai_client = nil
        @@openai_assistant_id = nil
        @@openai_thread_id = nil
        @@openai_message_ids = []

        # Create a new Copilot instance.
        def initialize(openai_api_key:)
            @@openai_api_key = openai_api_key
            @@openai_client = OpenAI::Client.new(access_token: @@openai_api_key)
        end

        # return array of available models.
        def models
            response = @@openai_client.models.list
            models = response['data'].map { |model| model['id'] }
            models
        end

        # start a new conversation (thread), and return the ID of such a new thread.
        def start(
            openai_model: ,
            instructions: ''
        )
            @@openai_model = openai_model
            response = @@openai_client.assistants.create(
                parameters: {
                    model: @@openai_model,         # Retrieve via client.models.list. Assistants need 'gpt-3.5-turbo-1106' or later.
                    name: "OpenAI-Ruby test assistant", 
                    description: nil,
                    instructions: instructions,
                    tools: [
                        {"type": "code_interpreter"}, 
                        {"type": "retrieval"}, 
                        ## Tag Operations
                        {
                            type: "function",
                            function: {
                                name: "Mass::Tag.page",
                                description: "Get list of tags.",
                                parameters: {
                                    type: :object,
                                    properties: {
                                        page: {
                                            type: :integer,
                                            description: "The number of page to return.",
                                        },
                                        limit: {
                                            type: :integer,
                                            description: "How many records per tag should retrieve.",
                                        },
                                        filters: {
                                            type: :object,
                                            description: "Filter parameters.",
                                            properties: {
                                                name: {
                                                    type: :string,
                                                    description: "Filter tags by name using partial match.",
                                                },
                                            },
                                        },
                                    },
                                    required: ["command"],
                                },    
                            },
                        },
                    ],
                    metadata: { my_internal_version_id: '1.0.0' },
                }
            )
            @@openai_assistant_id = response["id"]

            # Create thread
            response = @@openai_client.threads.create   # Note: Once you create a thread, there is no way to list it
                                                        # or recover it currently (as of 2023-12-10). So hold onto the `id` 
            @@openai_thread_id = response["id"]
        end

        # for internal use only
        def chat(prompt)
            mid = @@openai_client.messages.create(
                'thread_id': @@openai_thread_id,
                'parameters': {
                    'role': "user", # Required for manually created messages
                    'content': prompt, # Required.
                    #'image_url': 'https://static.vecteezy.com/system/resources/thumbnails/039/391/155/small/500-social-media-stories-v2-abstract-shopping-nft-e-commerce-memories-bundle.png',
                },
            )["id"]

            @@openai_message_ids << mid

            # run the assistant
            response = @@openai_client.runs.create(
                thread_id: @@openai_thread_id,
                parameters: {
                    assistant_id: @@openai_assistant_id,
                }
            )
            run_id = response['id']

            # wait for a response
            while true do    
                response = @@openai_client.runs.retrieve(id: run_id, thread_id: @@openai_thread_id)
                status = response['status']

                case status
                when 'queued', 'in_progress', 'cancelling'
                    sleep 1 # Wait one second and poll again
                when 'completed'
                    break # Exit loop and report result to user
                when 'requires_action'
                    tools_to_call = response.dig('required_action', 'submit_tool_outputs', 'tool_calls')

                    my_tool_outputs = tools_to_call.map { |tool|
                        # Call the functions based on the tool's name
                        function_name = tool.dig('function', 'name')
                        arguments = JSON.parse(
                              tool.dig("function", "arguments"),
                              { symbolize_names: true },
                        )

                        begin
                            tool_output = case function_name
                            when "Mass::Tag.page"
                                Mass::Tag.page(**arguments)
                            else
                                raise "Unknown function name: #{function_name}"
                            end

                            { tool_call_id: tool['id'], output: tool_output.to_s }
                        rescue => e
                            { tool_call_id: tool['id'], output: "Error: #{e.message}" }
                        end
                    }
                    @@openai_client.runs.submit_tool_outputs(
                        thread_id: @@openai_thread_id, 
                        run_id: run_id, 
                        parameters: { tool_outputs: my_tool_outputs }
                    )
                when 'cancelled', 'failed', 'expired'
                    raise response['last_error']['message']
                    break # or `exit`
                else
                    raise "Unknown run status response from OpenAI: #{status}"
                end
            end

            # 
            messages = @@openai_client.messages.list(thread_id: @@openai_thread_id) 
            messages['data'].first['content'].first['text']['value']
        end # def chat

        def console
            puts "Mass-Copilot Console".blue
            puts "Type 'exit' to quit.".blue
            while true
                print "You: ".green
                prompt = gets.chomp
                break if prompt == 'exit'
                begin
                    puts "Mass-Copilot: #{chat(prompt)}".blue
                rescue => e
                    puts "Error: #{e.message}".red
                    puts e.to_console
                end
            end
        end # def console

    end # class Copilot
end # module Mass

Requirement Prompt Example

This is the stub class Mass::Outreach

module Mass
    class Outreach < BlackStack::Base
        attr_accessor :type, :profile, :lead, :company, :profile_type

        def self.object_name
            'outreach'
        end

        def initialize(h)
            super(h)
            self.type = Mass::OutreachType.new(h['outreach_type_desc']) if h['outreach_type_desc']
            self.profile = Mass::Profile.new(h['profile']).child_class_instance if h['profile']
            self.lead = Mass::Lead.new(h['lead']) if h['lead']
            self.company = Mass::Company.new(h['company']) if h['company']
            self.profile_type = Mass::ProfileType.page(
                id_account: h['id_account'],
                page: 1,
                limit: 1,
                filters: {
                    name: h['profile_type']
                }
            ).first.child_class_instance if h['profile_type']
        end

        # convert the outreach_type into the ruby class to create an instance.
        # example: Apollo --> Mass::ApolloAPI
        def class_name_from_outreach_type
            outreach_type = self.desc['outreach_type']
            "Mass::#{outreach_type}" 
        end

        # crate an instance of the profile type using the class defined in the `desc['name']` attribute.
        # override the base method
        def child_class_instance
            outreach_type = self.desc['outreach_type']
            key = self.class_name_from_outreach_type
            raise "Source code of outreach type #{outreach_type} not found. Create a class #{key} in the folder `/lib` of your mass-sdk." unless Kernel.const_defined?(key)
            ret = Kernel.const_get(key).new(self.desc)
            return ret
        end

    end # class Outreach
end # module Mass

This is the skeleton class Mass::Outreach

module Mass
    class Outreach < Sequel::Model(:outreach)
        extend Mass::State # make its methods as class methods
        extend BlackStack::Status # make its methods as class methods
        include BlackStack::Serialize # make its methods as class methods
        extend BlackStack::Validation 
        extend Mass::Body # make its methods as class methods
        extend Mass::Direction # make its methods as class methods
        extend BlackStack::InsertUpdate # make its methods as class methods
        extend Mass::TimelinePendings
        extend Mass::Skip

        many_to_one :account, :class => 'BlackStack::MySaaS::Account', :key => :id_account
        many_to_one :user, :class => 'BlackStack::MySaaS::User', :key => :id_user

        many_to_one :profile, :class => 'Mass::Profile', :key => :id_profile
        many_to_one :outreach_type, :class => 'Mass::OutreachType', :key => :id_outreach_type
        many_to_one :lead, :class => 'Mass::Lead', :key => :id_lead
        many_to_one :company, :class => 'Mass::Company', :key => :id_company
        many_to_one :tag, :class => 'Mass::Tag', :key => :id_tag

        # track an outreach has been created by an action.
        many_to_one :action, :class => 'Mass::Action', :key => :id_action

        # Cancels all pending outreaches linked to paused rules.
        #
        # This method identifies all outreach records that are in the 'pending' state and linked to rules that have been paused.
        # It then updates their status to 'canceled' in batches, to ensure efficient processing and minimize database load.
        #
        # @param batch_size [Integer] The number of records to process in each batch. Default is 100.
        # @param logger [Object] A logger instance to log the progress of the operation. Defaults to BlackStack::DummyLogger if not provided.
        #
        # @example
        #   # Cancel all pending outreaches linked to paused rules with the default batch size and no custom logger.
        #   Outreach.cancel
        #
        # @example
        #   # Cancel all pending outreaches with a batch size of 50 and a custom logger.
        #   Outreach.cancel(batch_size: 50, logger: custom_logger)
        #
        # @return [void]
        #
        # The method iterates over the list of pending outreaches in batches. Each batch is then updated in bulk to
        # improve efficiency, and logging is used to track the progress of each batch processed.
        #
        def self.cancel(
            batch_size:100, 
            logger:nil
        )
            l = logger || BlackStack::DummyLogger.new(nil)

            # Constants for status codes
            pending_status = Mass::Outreach.status_code(:pending)
            canceled_status = Mass::Outreach.status_code(:canceled)

            # Define the batch size
            batch_size = 100

            # Fetch the list of pending outreaches linked to paused rules
            l.logs "Get pending outreaches linked to paused rules... "
            pending_outreaches = DB[:rule].join(
                :rule_instance, id_rule: :id
            ).join(
                :outreach, id_rule_instance: :id
            ).where(
                active: false
            ).where(
                Sequel[:outreach][:status] => pending_status
            ).select(Sequel[:outreach][:id])
            l.done(details: pending_outreaches.count.to_s.blue)

            # Process in batches of 100
            i = 0
            pending_outreaches.paged_each(
                rows_per_fetch: batch_size
            ) do |outreach|
                i += 1
                l.logs "Cancel pending outreaches linked to paused rules (batch #{i.to_s.blue})... "
                # Collect outreach IDs in the current batch
                outreach_ids = []
                outreach_ids << outreach[:id]
                # Update the status of the collected outreach records to canceled
                DB[:outreach].where(id: outreach_ids).update(status: canceled_status)
                l.done
            end
        end # def self.cancel

        # Reactivates all canceled outreaches linked to active rules.
        #
        # This method identifies all outreach records that are in the 'canceled' state and linked to rules that are currently active.
        # It then updates their status to 'pending' in batches to ensure efficient processing and minimize database load.
        #
        # @param batch_size [Integer] The number of records to process in each batch. Default is 100.
        # @param logger [Object] A logger instance to log the progress of the operation. Defaults to BlackStack::DummyLogger if not provided.
        #
        # @example
        #   # Reactivate all canceled outreaches linked to active rules with the default batch size and no custom logger.
        #   Outreach.reactivate
        #
        # @example
        #   # Reactivate all canceled outreaches with a batch size of 50 and a custom logger.
        #   Outreach.reactivate(batch_size: 50, logger: custom_logger)
        #
        # @return [void]
        #
        # The method iterates over the list of canceled outreaches in batches, reactivating each batch to a 'pending' status.
        # Batch processing ensures database efficiency, and each batch is logged for easier monitoring and troubleshooting.
        #
        def self.reactivate(
            batch_size: 100, 
            logger: nil
        )
            l = logger || BlackStack::DummyLogger.new(nil)

            # Constants for status codes
            canceled_status = Mass::Outreach.status_code(:canceled)
            pending_status = Mass::Outreach.status_code(:pending)

            # Fetch the list of canceled outreaches linked to active rules
            l.logs "Get canceled outreaches linked to active rules... "
            canceled_outreaches = DB[:rule].join(
                :rule_instance, id_rule: :id
            ).join(
                :outreach, id_rule_instance: :id
            ).where(
                active: true
            ).where(
                Sequel[:outreach][:status] => canceled_status
            ).select(Sequel[:outreach][:id])
            l.done(details: canceled_outreaches.count.to_s.blue)

            # Process in batches of specified size
            i = 0
            outreach_ids = [] # Collect outreach IDs for bulk updates

            canceled_outreaches.paged_each(
                rows_per_fetch: batch_size
            ) do |outreach|
                outreach_ids << outreach[:id]

                # If the batch is full or we are at the end, perform the update
                if outreach_ids.size >= batch_size
                    i += 1
                    l.logs "Reactivate canceled outreaches linked to active rules (batch #{i.to_s.blue})... "

                    # Update the status of the collected outreach records to pending
                    DB[:outreach].where(id: outreach_ids).update(status: pending_status)
                    l.done

                    # Clear the batch
                    outreach_ids.clear
                end
            end

            # Update any remaining records not included in a full batch
            unless outreach_ids.empty?
                i += 1
                l.logs "Reactivate remaining canceled outreaches (batch #{i.to_s.blue})... "

                # Update the status of the remaining outreach records to pending
                DB[:outreach].where(id: outreach_ids).update(status: pending_status)
                l.done
            end
        end        

        # Unassigns all pending outreaches linked to deleted profiles.
        #
        # This method identifies all outreach records that are in the 'pending' state and linked to profiles that have been deleted.
        # It then removes the profile assignment to ensure data integrity.
        #
        # @param batch_size [Integer] The number of records to process in each batch. Default is 100.
        # @param logger [Object] A logger instance to log the progress of the operation. Defaults to BlackStack::DummyLogger if not provided.
        #
        # @example
        #   # Unassign all pending outreaches linked to deleted profiles with the default batch size and no custom logger.
        #   Outreach.unassign
        #
        # @example
        #   # Unassign all pending outreaches with a batch size of 50 and a custom logger.
        #   Outreach.unassign(batch_size: 50, logger: custom_logger)
        #
        # @return [void]
        #
        def self.unassign(
            batch_size: 100,
            logger: nil
        )
            l = logger || BlackStack::DummyLogger.new(nil)

            # Status code for pending outreaches
            pending_status = Mass::Outreach.status_code(:pending)

            # Fetch the list of pending outreaches linked to deleted profiles
            l.logs "Get pending outreaches linked to deleted profiles... "
            pending_outreaches = DB[:outreach].join(
                :profile, Sequel[:outreach][:id_profile] => Sequel[:profile][:id]
            ).where(
                Sequel[:outreach][:status] => pending_status
            ).where(
                Sequel[:profile][:delete_time] => Sequel::NOTNULL
            ).select(Sequel[:outreach][:id])
            l.done(details: pending_outreaches.count.to_s.blue)

            # Process in batches of specified size
            i = 0
            outreach_ids = [] # Collect outreach IDs for bulk updates

            pending_outreaches.paged_each(
                rows_per_fetch: batch_size
            ) do |outreach|
                outreach_ids << outreach[:id]

                # If the batch is full or we are at the end, perform the unassignment
                if outreach_ids.size >= batch_size
                    i += 1
                    l.logs "Unassign pending outreaches linked to deleted profiles (batch #{i.to_s.blue})... "

                    # Update the outreach records to unassign profiles (setting id_profile to nil)
                    DB[:outreach].where(id: outreach_ids).update(id_profile: nil)
                    l.done

                    # Clear the batch
                    outreach_ids.clear
                end
            end

            # Update any remaining records not included in a full batch
            unless outreach_ids.empty?
                i += 1
                l.logs "Unassign remaining pending outreaches (batch #{i.to_s.blue})... "

                # Update the outreach records to unassign profiles
                DB[:outreach].where(id: outreach_ids).update(id_profile: nil)
                l.done
            end
        end

        def basic_access?
            self.outreach_type.basic_access?
        end

        # generate the CLI command to run this job
        def command(url)
            return nil if self.account.api_key.to_s.empty?
            return nil if self.id_profile.nil? && !basic_access?
            if self.basic_access?
                return "ruby basic.rb " +
                "api_key=#{self.account.api_key} " +
                "api_url=#{url} " + 
                "ignore-hostname=yes " +
                "force-profile-run=yes " +
                "run-once=yes " +
                "scraping=no " +
                "enrichment=no " +
                "outreach=yes " + 
                "id_outreach=#{self.id} "
            else
                return "ruby profile.rb " +
                "api_key=#{self.account.api_key} " +
                "api_url=#{url} " + 
                "id=#{self.id_profile} " +
                "ignore-hostname=yes " +
                "force-profile-run=yes " +
                "headless=no " +
                "run-once=yes " +
                "inboxcheck=no " +
                "connectioncheck=no " +
                "scraping=no " +
                "enrichment=no " +
                "outreach=yes " + 
                "id_outreach=#{self.id} "
            end
        end

        # if the access of the profile_type of the outrech_type of this outreach IS NOT :mta, then:
        # - return the merged body.
        #
        # if the outreach direction is :outgoing, then:
        # - return the body as is.
        #
        # if the access of the profile_type of the outrech_type of this outreach IS :mta, then:
        # - return either html version of the body, assuming it is the full mime content.
        # - reference: https://stackoverflow.com/questions/4868205/rails-mail-getting-the-body-as-plain-text
        #
        # TODO: Also, remove open tracking pixel
        def simplified_body(preview: false)
            # if the outreach direction is :outgoing, then:
            if self.direction == self.class.direction_code(:outgoing)
                return self.merged_body(preview: preview)
            end

            # if the access of the profile_type of the outrech_type of this outreach IS NOT :mta.
            if self.outreach_type.profile_type.access != Mass::ProfileType.access_code(:mta)
                return self.body
            end

            # if the access of the profile_type of the outrech_type of this outreach IS :mta.
            ret = nil
            # get the HTML body
            m = Mail.new(self.body.to_s)
            if m.html_part
                ret = m.html_part.body.decoded
            elsif m.body
                ret = m.body.decoded
            end
            # even if I activated the preview flag I must remove the tracking pixel, links and unssubscribe link from quoted text.
            # reference: https://github.com/ConnectionSphere/emails/issues/121
            doc = Nokogiri::HTML(ret)
            doc.css('style').remove # remove all css
            doc.css('script').remove # remove all javascript
            doc.xpath('//@style').remove # remove all inline css - https://stackoverflow.com/questions/6096327/strip-style-attributes-with-nokogiri
            # remove tracking pixel
            # reference: https://github.com/ConnectionSphere/emails/issues/99
            doc.css('img').each do |img|
                if img['src'] && img['src'].include?('/o/')
                    img.remove
                end
            end
            # replace url of unsubscribe link
            # reference: https://github.com/MassProspecting/docs/issues/165
            doc.css('a').each do |a|
                if a['href'] && a['href'].include?('/u/')
                    a['href'] = '#'
                end
            end
            # replace url of tracking links by their original url
            # reference: https://github.com/MassProspecting/docs/issues/165
            doc.css('a').each do |a|
                if a['href'] && a['href'].include?('/c/')
                    lid = a['href'].split('/').last
                    l = Mass::Link.where(:id => lid).first
                    if l
                        a['href'] = l.url
                    end
                end
            end
            # get the processed body
            body = doc.at('body')
            if body
                return body.inner_html
            else
                return doc.to_s
            end
        end # def simplified_body

        # If text is nil, return nil.
        # Generic method for internal use only.
        # Replace merge-tags.
        # Replace still peding merge-tags by their fallback spintax.
        # Apply spintax.
        #
        # Parameters:
        # - text: Content to replace merge-tags.
        # 
        def generate_merged_text(text, preview: false)
            return nil if text.nil?
            raise "Outreach must have a profile assigned for merging." if self.profile.nil?

            ret = text.dup
            p = self.profile

            # replace merge-tags
            unsubscribe_url = '#'
            if !preview
                unsubscribe_url = "#{p.tracking_url}/u/#{self.id.to_guid}" if self.lead
                unsubscribe_url = "#{p.tracking_url}/u/#{self.id.to_guid}" if self.company
            end
            ret.gsub!('{unsubscribe-url}', unsubscribe_url)

            if self.action.nil?
                ret.gsub!('{unsubscribe-link}', unsubscribe_url)
            elsif self.action.body_type == Mass::Action.body_code(:html)
                ret.gsub!('{unsubscribe-link}', "<a href='#{unsubscribe_url}' style='font-size:10px;'>Unsubscribe</a>")
            else
                ret.gsub!('{unsubscribe-link}', "unsubscribe: #{unsubscribe_url}")
            end

            #ret.gsub!(/\{company\-name[\&.*\;]*[\|[^\|^\}]*]*\}/, self.company.name) if self.company && self.company.name
            ret.gsub!(/\{company\-name[\&.*\;]*[\|[^\|^\}]*]*\}/, self.lead.company.name) if self.lead && self.lead.company && self.lead.company.name

            ret.gsub!(/\{first\-name[\&.*\;]*[\|[^\|^\}]*]*\}/, self.lead.first_name) if self.lead && self.lead.first_name
            ret.gsub!(/\{last\-name[\&.*\;]*[\|[^\|^\}]*]*\}/, self.lead.last_name) if self.lead && self.lead.last_name

            ret.gsub!(/\{job\-title[\&.*\;]*[\|[^\|^\}]*]*\}/, self.lead.job_title) if self.lead && self.lead.job_title

            ret.gsub!(/\{location[\&.*\;]*[\|[^\|^\}]*]*\}/, self.lead.location.name) if self.lead && self.lead.location && self.lead.location.name
            ret.gsub!(/\{location[\&.*\;]*[\|[^\|^\}]*]*\}/, self.lead.company.location.name) if self.lead && self.lead.company && self.lead.company.location && self.lead.company.location.name
            #ret.gsub!(/\{company\-location[\&.*\;]*[\|[^\|^\}]*]*\}/, self.lead.company.location.name) if self.company && self.company.location && self.company.location.name
            #ret.gsub!(/\{company\-industry[\&.*\;]*[\|[^\|^\}]*]*\}/, self.lead.company.company_industry.name) if self.company && self.company.company_industry.first && self.company.company_industry.first.industry.name

            ret.gsub!(/\{email[\&.*\;]*[\|[^\|^\}]*]*\}/, self.lead.email) if self.lead && self.lead.email
            ret.gsub!(/\{email[\&.*\;]*[\|[^\|^\}]*]*\}/, self.lead.company.email) if self.lead && self.lead.company && self.lead.company.email
            #ret.gsub!(/\{company\-email[\&.*\;]*[\|[^\|^\}]*]*\}/, self.company.email) if self.company && self.company.email

            ret.gsub!(/\{phone[\&.*\;]*[\|[^\|^\}]*]*\}/, self.lead.phone) if self.lead && self.lead.phone
            ret.gsub!(/\{phone[\&.*\;]*[\|[^\|^\}]*]*\}/, self.lead.company.phone) if self.lead && self.lead.company && self.lead.company.phone
            #ret.gsub!(/\{company\-phone[\&.*\;]*[\|[^\|^\}]*]*\}/, self.company.phone) if self.company && self.company.phone

            ret.gsub!(/\{linkedin[\&.*\;]*[\|[^\|^\}]*]*\}/, self.lead.linkedin) if self.lead && self.lead.linkedin
            ret.gsub!(/\{linkedin[\&.*\;]*[\|[^\|^\}]*]*\}/, self.lead.company.linkedin) if self.lead && self.lead.company && self.lead.company.linkedin
            #ret.gsub!(/\{company\-linkedin[\&.*\;]*[\|[^\|^\}]*]*\}/, self.company.linkedin) if self.company && self.company.linkedin

            ret.gsub!(/\{facebook[\&.*\;]*[\|[^\|^\}]*]*\}/, self.lead.facebook) if self.lead && self.lead.facebook
            ret.gsub!(/\{facebook[\&.*\;]*[\|[^\|^\}]*]*\}/, self.lead.company.facebook) if self.lead && self.lead.company && self.lead.company.facebook
            #ret.gsub!(/\{company\-facebook[\&.*\;]*[\|[^\|^\}]*]*\}/, self.company.facebook) if self.company && self.company.facebook

            # replace merge-tags with fallback values by the a spintax
            # example: any substring like {company-name>an amazing company|a great company} is replaced by {an amazing company|a great company} by removing the {company-name> substring.
            Mass::mergetags.each { |r|
                # iterate all the substrings /\{first\-name[\&.*\;]*\|[^\}]*\}/
                r.gsub!(/^{/, '')
                r.gsub!(/}$/, '')
                r = Regexp.escape(r) #r.gsub!(/\-/, '\-')
                ret.scan(/\{#{r}[\&.*\;]*[\|[^\|^\}]*]*\}/).each { |s|
                    ret.gsub!(s, s.gsub(/\{#{r}[\&.*\;]*\|/, '{'))
#ret.gsub(s, s.gsub(/\{#{r}[\&.*\;]*\|/, '{'))
                }
            }

            # apply spintax 
            ret = ret.spin

            # return
            return ret
        end # def merged_subject

        # If body is nil, return nil.
        # raise an exception if track_clicks is activated and the outreach has not a profile assigned.
        # raise an exception if track_opens is activated and the outreach has not a profile assigned.
        # if the outreach has been created by an action, and the action.tracking_clicks in on, and the action.body_type is :html, then replace merge-tags in the body.
        # if the outreach has been created by an action, and the action.tracking_opens in on, and the action.body_type is :html, then inject tracking pixel.
        # Replace merge-tags.
        # Replace still peding merge-tags by their fallback spintax.
        # Apply spintax.
        #
        # Parameters:
        # - preview: boolean. If true, don't apply the open-tracking pixel, and don't apply the tracking links.
        #
        def merged_body(preview: false)
            return nil if self.body.nil?
            ret = self.body.dup

            track_clicks = false
            track_opens = false
            body_type = Mass::Action.body_code(:plain)
            if self.action
                track_clicks = self.action.track_clicks
                track_opens = self.action.track_opens
                body_type = self.action.body_type
            end

            err = []
            # raise an exception if track_clicks is activated and the outreach has not a profile assigned.
            err << "merged_body: track_clicks cannot be true if the outreach has not a profile assigned." if track_clicks && self.id_profile.nil?
            # raise an exception if track_opens is activated and the outreach has not a profile assigned.
            err << "merged_body: track_opens cannot be true if the outreach has not a profile assigned." if track_opens && self.id_profile.nil?
            # 
            raise err.join(' ') if err.size > 0

            # apply tracking links.
            # iterate all href attributes of anchor tags
            # reference: https://stackoverflow.com/questions/53766997/replacing-links-in-the-content-with-processed-links-in-rails-using-nokogiri
            if self.action && self.action.track_clicks && !preview
                n = 0
                p = self.profile
                fragment = Nokogiri::HTML.fragment(ret)
                fragment.css("a[href]").each do |link| 
                    # increment the URL counter
                    n += 1
                    # get the link
                    l = Mass::Link.where(:id_action=>self.action.id, :url=>link['href'], :delete_time=>nil).first
                    ## replace the link with the tracking link
                    click_tracking_url = "#{p.tracking_url}/c/#{self.id}/#{l.id}"
                    link.content = click_tracking_url if link.text == link['href'] && self.action.body_type == Mass::Action.body_code(:plain)
                    link['href'] = click_tracking_url
                end
                # update notification body.
                ret = fragment.to_html
                # this forcing is because of this glitch: https://github.com/sparklemotion/nokogiri/issues/1127
                ret.gsub!(/#{Regexp.escape('&amp;')}/, '&')
                # return
                ret
            end # if track_clicks

            # apply tracking pixel
            if self.action && self.action.track_opens && self.action.body_type == Mass::Action.body_code(:html) && !preview
                p = self.profile
                pixel = "<img src='#{p.tracking_url}/o/#{self.id}' height='1px' width='1px' />"
                if ret =~ /<\/body>/
                    ret = ret.gsub(/<\/body>/, "#{pixel}</body>")
                else
                    ret += pixel
                end
            end # if track_opens

            # apply merge-tags, fallbacks and spintax
            ret = self.generate_merged_text(ret, preview: preview)

            # return
            return ret
        end # def merged_body

        def merged_subject
            return nil if self.subject.nil?
            ret = self.subject.dup
            ret = self.generate_merged_text(ret)
            return ret
        end # def merged_subject

        # return a Sequel dataset, based on some filters.
        # this method is used by the API to get the data from the database remotely
        #
        # Supported filters:
        # - id_profile: string. Name of the profile type. Key sensitive.
        # - status: string. Status of the outreach. Key sensitive. Allowed values are: pending, error, done.
        # - approved: boolean. If the outreach is approved or not.
        # - id_outreach_type: string. Name of the outreach type. Key sensitive.
        # - id_tag: string. ID of the tag. Key sensitive. 
        # - direction: string. Direction of the outreach. Key sensitive. Allowed values are: incoming, outgoing, accepted.
        # - basic: boolean. If the outreach is basic access or not.
        # 
        def self.list(account, filters:)
            err = key_errors(filters, allowed_keys: [:id_profile, :id_lead, :id_company, :status, :approved, :outreach_type, :outreach_type_desc, :tag, :direction, :basic, :reply_to_tag])
            ds = self.base_list(account, filters: filters)
            filters.each { |k, v|
                if k.to_s == 'id_profile'
                    if !account
                        err << "Account is required to filter by id_profile."
                    else
                        p = Mass::Profile.where(:id => v.to_s, :id_account => account.id).first
                        if p.nil?
                            err << "Unknown profile #{v.to_s}."
                        else
                            ds = ds.where(:id_profile => p.id)
                        end
                    end
                elsif k.to_s == 'id_lead'
                    if !account
                        err << "Account is required to filter by id_lead."
                    else
                        p = Mass::Lead.where(:id => v.to_s, :id_account => account.id, :delete_time => nil).first
                        if p.nil?
                            err << "Unknown lead #{v.to_s}."
                        else
                            ds = ds.where(:id_lead => p.id)
                        end
                    end
                elsif k.to_s == 'id_company'
                    if !account
                        err << "Account is required to filter by id_company."
                    else
                        p = Mass::Company.where(:id => v.to_s, :id_account => account.id, :delete_time => nil).first
                        if p.nil?
                            err << "Unknown company #{v.to_s}."
                        else
                            ds = ds.where(:id_company => p.id)
                        end
                    end
                elsif k.to_s == 'status'
                    code = self.status_code(v.to_sym)
                    err << "Unknown status #{v.to_s}. Allowed values are: #{statuses.join(', ')}" if code.nil?
                    ds = ds.where(:status => code) if code
                elsif k.to_s == 'approved'
                    ds = ds.where(:approved => v.to_s)
                elsif k.to_s == 'outreach_type'
                    if !account
                        err << "Account is required to filter by outreach_type."
                    else
                        ot = Mass::OutreachType.where(:id_account => account.id, :name => v.to_s).first
                        err << "Unknown outreach type #{v.to_s}." if ot.nil?
                        ds = ds.where(:id_outreach_type => ot.id) if ot
                    end
                elsif k.to_s == 'tag'
                    if !account
                        err << "Account is required to filter by tag."
                    else
                        tag = Mass::Tag.where(:id_account => account.id, :name => v.to_s).first
                        err << "Unknown tag #{v.to_s}." if tag.nil?
                        ds = ds.where(:id_tag => tag.id) if tag
                    end

                # only :outgoing outreaches has tags.
                # both :incoming and :accepted outreaches have not tag assigned.
                # if I am looking for :incoming or :accepted who with a previous :outgoing tag, then I must use the `reply_to_tag` filter.
                elsif k.to_s == 'reply_to_tag'
                    if !account
                        err << "Account is required to filter by reply_to_tag."
                    else
                        tag = Mass::Tag.where(:id_account => account.id, :name => v.to_s).first

                        # get the first outreach with :outgoing directiom, with done_time not null, and with the tag; sorted by done_time desc.
                        ds = ds.where(
                            Sequel.lit("
                                (
                                    select o2.id_tag 
                                    from outreach o2
                                    where
                                        o2.id_account = outreach.id_account AND
                                        coalesce(o2.id_profile,'897b4c5e-692e-400f-bc97-8ee0e3e1f1cf') = coalesce(outreach.id_profile,'897b4c5e-692e-400f-bc97-8ee0e3e1f1cf') AND
                                        coalesce(o2.id_lead,'897b4c5e-692e-400f-bc97-8ee0e3e1f1cf') = coalesce(outreach.id_lead,'897b4c5e-692e-400f-bc97-8ee0e3e1f1cf') AND
                                        coalesce(o2.id_company,'897b4c5e-692e-400f-bc97-8ee0e3e1f1cf') = coalesce(outreach.id_company,'897b4c5e-692e-400f-bc97-8ee0e3e1f1cf') AND
                                        o2.id_outreach_type = outreach.id_outreach_type AND
                                        --o2.id_tag = '#{tag.id}' AND
                                        o2.status = #{self.status_code(:performed)} AND
                                        o2.direction = #{self.direction_code(:outgoing)} AND
                                        o2.delete_time IS NULL AND
                                        o2.done_time IS NOT NULL AND
                                        o2.done_time < outreach.create_time
                                    order by o2.done_time desc
                                    limit 1
                                ) = '#{tag.id}'
                            ")
                        )
                    end

                elsif k.to_s == 'direction'
                    ds = ds.where(:direction => self.direction_code(v.to_sym))
                elsif k.to_s == 'basic'
                    # get list of all enrichment_types with basic access
                    outreach_types = []
                    if account
                        outreach_types = Mass::OutreachType.where(:id_account => account.id, :delete_time => nil).all.select { |o| o.basic_access? }
                    else
                        outreach_types = Mass::OutreachType.where(:delete_time => nil).all.select { |o| o.basic_access? }
                    end
                    ds = ds.where(:id_outreach_type => outreach_types.map { |o| o.id }) if v
                    ds = ds.exclude(:id_outreach_type => outreach_types.map { |o| o.id }) if !v
                else
                    err << "Unknown filter #{k.to_s} for #{self.name}.list method."
                end
            }

            raise err.join("\n") if err.size > 0
            return ds
        end # def self.list

        # return an array of error messages
        def self.errors(h={})
            ret = []
            ret += self.key_errors(h, allowed_keys: [
                :id_account,
                :id_user,
                :profile,
                :tag,
                :approved, 
                :skip_repliers,
                :direction,
                :status, :outreach_type, :lead, :company, :subject, :body, :body_type, :error_description, :screenshot_url, :snapshot_url, 
                :message_id, :reply_to_message_id,
                # dummy parameters, not stored in the database by returned by to_h method
                :outreach_type_desc,
                :merged_subject, :merged_body, :merged_body_text, :lead_or_company,
                :action,
            ])
            ret += self.ownership_errors(h) 
            ret += self.mandatory_errors(h, keys: [
                :outreach_type, :status
            ])
            ret += self.boolean_errors(h, keys: [:approved])
            ret += self.url_errors(h, keys: [ :screenshot_url ])
            ret += self.string_errors(h, keys: [ :subject, :body, :error_description, :message_id, :reply_to_message_id])

            ret << "Value `id` must be  a GUID." if h['id'] && !h['id'].to_s.guid?

            ret << "Value `status` must be a symbol or string." if h['status'] && !h['status'].is_a?(Symbol) && !h['status'].is_a?(String)
            ret << "Unkown `status` #{h['status']}. Allowed values are: #{statuses.join(', ')}" if h['status'] && !statuses.include?(h['status'].to_sym)

            ret << "Unknown skip repliers `#{h['skip_repliers'].to_s}`." if h['skip_repliers'] && !h['skip_repliers'].nil? && !skips.include?(h['skip_repliers'].to_sym)

            ret << "Value `direction` must be a symbol or string." if h['direction'] && !h['direction'].is_a?(Symbol) && !h['status'].is_a?(String)
            ret << "Unkown `direction` #{h['direction']}. Allowed values are: #{directions.join(', ')}" if h['direction'] && !directions.include?(h['direction'].to_sym)

            ret << "Value `body_type` must be a symbol or string." if h['body_type'] && !h['body_type'].is_a?(Symbol) && !h['body_type'].is_a?(String)
            ret << "Unkown `body_type` #{h['body_type']}. Allowed values are: #{bodies.join(', ')}" if h['body_type'] && !bodies.include?(h['body_type'].to_sym)

            ret << "Value `outreach_type` must be a symbol or string." if h['outreach_type'] && !h['outreach_type'].is_a?(Symbol) && !h['outreach_type'].is_a?(String)
            ret << "Unkown `outreach_type` #{h['outreach_type']}." if h['outreach_type'] && !Mass::OutreachType.where(:id_account => h['id_account'], :name => h['outreach_type'].to_s, :delete_time => nil).first

            ret << "Value `tag` must be a symbol or string." if h['tag'] && !h['tag'].is_a?(Symbol) && !h['tag'].is_a?(String)
            ret << "Unkown `tag` #{h['tag']}." if h['tag'] && !Mass::Tag.where(:id_account => h['id_account'], :name => h['tag'].to_s).first

            ret << "Value `subject` must be a string." if h['subject'] && !h['subject'].is_a?(String)
            ret << "Value `body` must be a string." if h['body'] && !h['body'].is_a?(String)

            ret << "Value `error_description` must be a string." if h['error_description'] && !h['error_description'].is_a?(String)

            # message_id is allowd if the profile_type of the outreach is :mta only
            if h['message_id'] || h['reply_to_message_id']
                ot = Mass::OutreachType.where(:id_account => h['id_account'], :name => h['outreach_type'].to_s, :delete_time => nil).first
                ret << "message_id is allowed if the outreach_type is :mta only." if h['message_id'] && ot && ot.profile_type.access != Mass::ProfileType.access_code(:mta)
                ret << "reply_to_message_id is allowed if the outreach_type is :mta only." if h['reply_to_message_id'] && ot && ot.profile_type.access != Mass::ProfileType.access_code(:mta)
            end

            # either lead or company or lead_or_company must be present
            #ret << "Either `lead` or `company` must be present." if !h['lead'] && !h['company']
            ret << "Either `lead` or `company` or `lead_or_company` must be present." if !h['lead'] && !h['company'] && !h['lead_or_company']

            # only one of lead or company or lead_or_company must be present
            #ret << "Both `lead` and `company` cannot be present at the same time." if h['lead'] && h['company']
            ret << "Only one of `lead` or `company` or `lead_or_company` must be present." if h['lead'] && h['company']
            ret << "Only one of `lead` or `company` or `lead_or_company` must be present." if h['lead'] && h['lead_or_company']
            ret << "Only one of `lead` or `company` or `lead_or_company` must be present." if h['company'] && h['lead_or_company']                

            #if h['outreach_type']
            #    i = h['outreach_type']
            #    i['id_account'] = h['id_account']
            #    i['id_user'] = h['id_user']
            #    ret += Mass::OutreachType::errors(i) 
            #end

            if h['lead']
                i = h['lead']
                i['id_account'] = h['id_account']
                i['id_user'] = h['id_user']
                ret += Mass::Lead::errors(i) 
            end

            if h['company']
                i = h['company']
                i['id_account'] = h['id_account']
                i['id_user'] = h['id_user']
                ret += Mass::Company::errors(i) 
            end

            if h['lead_or_company']
                i = h['lead_or_company']
                i['id_account'] = h['id_account']
                i['id_user'] = h['id_user']
                ret1 = Mass::Lead::errors(i) 
                ret2 = Mass::Company::errors(i) 
                if ret1.size > 0 && ret2.size > 0
                    ret << "The `lead_or_company` is not a valid lead and is not a valid company."
                    ret += ret1
                    ret += ret2
                end
            end

            return ret
        end # def errors

        # Find an outreach by id.
        # Find an outreach by id_profile, id_lead and body.
        # Find an outreach by id_profile, id_company and body.
        def self.find(h={})
            o = nil

            l = nil
            if h['lead']
                h['lead']['id_account'] = h['id_account']
                h['lead']['id_user'] = h['id_user']
                l = Mass::Lead.find(h['lead'])
            end

            c = nil
            if h['company']
                h['company']['id_account'] = h['id_account']
                h['company']['id_user'] = h['id_user']
                c = Mass::Company.find(h['company'])
            end

            p = nil
            if h['profile']
                h['profile']['id_account'] = h['id_account']
                h['profile']['id_user'] = h['id_user']
                p = Mass::Profile.find(h['profile'])
            end

            if o.nil? && h['id']
                o = self.where(:id_account => h['id_account'], :id => h['id']).first
            end
            if o.nil? && p && h['lead']
                o = self.where(:id_account => h['id_account'], :id_profile => p.id, :id_lead => l.id, :body => h['body']).first if p && l
            end
            if o.nil? && p && h['company']
                c = Mass::Company.find(h['company'])
                o = self.where(:id_account => h['id_account'], :id_profile => p.id, :id_company => c.id, :body => h['body']).first if p && c
            end

            return o
        end # def self.find

        # monkey patching the upsert method defined in the module InsertUpdate.
        #
        # If the profile assigned to this outreach is leased, then:
        # - if the lead or doesn't exist in the CRM of the accunt, ignore the outreach.
        # Else:
        # - Upsert the outreach with its leads and company as is.
        # 
        # If the lead has repied from a differnt email address, then:
        # - Find the lead by finding the previous outreach with the `reply_to_message_id` equals to its `message_id`.
        # - Save this new email in the email_2 field of the lead.
        #
        # parameters:
        # - upsert_children: call upsert on children objects, or call insert on children objects
        # 
        # return the object
        def self.upsert(h={}, upsert_children: true)
            el = nil
            ec = nil
            if h['profile']['leased']
                if h['lead']
                    i = h['lead']
                    i['id_account'] = h['id_account']
                    i['id_user'] = h['id_user']
                    el = Mass::Lead.find(i)
                    # if I didn't find the lead, try to find it using the email_2 field.
                    if el.nil?
                        el = Mass::Lead.where(
                            Sequel.lit("
                                id_account = '#{i['id_account']}' AND
                                delete_time IS NULL AND
                                email_2 IS NOT NULL AND
                                email_2 = '#{i['email'].to_s.to_sql}'
                            ")
                        ).first
                    end # el.nil?

                elsif h['company']
                    i = h['company']
                    i['id_account'] = h['id_account']
                    i['id_user'] = h['id_user']
                    ec = Mass::Company.find(i)
                    # if I didn't find the lead, try to find it using the email_2 field.
                    if ec.nil?
                        ec = Mass::Company.where(
                            Sequel.lit("
                                id_account = '#{i['id_account']}' AND
                                delete_time IS NULL AND
                                email_2 IS NOT NULL AND
                                email_2 = '#{i['email'].to_s.to_sql}'
                            ")
                        ).first
                    end # ec.nil?

                elsif h['lead_or_company']
                    i = h['lead_or_company']
                    i['id_account'] = h['id_account']
                    i['id_user'] = h['id_user']

                    ret1 = Mass::Lead::errors(i) 
                    ret2 = Mass::Company::errors(i) 

                    buffl = nil
                    buffc = nil

                    if ret1.size == 0
                        buffl = Mass::Lead.where(:id_account=>i['id_account'], :delete_time=>nil, :email=>i['email']).first
                        # if I didn't find the lead, try to find it using the email_2 field.
                        if buffl.nil?
                            buffl = Mass::Lead.where(
                                Sequel.lit("
                                    id_account = '#{i['id_account']}' AND
                                    delete_time IS NULL AND
                                    email_2 IS NOT NULL AND
                                    email_2 = '#{i['email'].to_s.to_sql}'
                                ")
                            ).first
                        end # buffl.nil?
                    end

                    if ret2.size == 0 && buffl.nil?
                        buffc = Mass::Company.where(:id_account=>i['id_account'], :delete_time=>nil, :email=>i['email']).first
                        # if I didn't find the lead, try to find it using the email_2 field.
                        if buffc.nil?
                            buffc = Mass::Company.where(
                                Sequel.lit("
                                    id_account = '#{i['id_account']}' AND
                                    delete_time IS NULL AND
                                    email_2 IS NOT NULL AND
                                    email_2 = '#{i['email'].to_s.to_sql}'
                                ")
                            ).first
                        end # buffc.nil?
                    end

                    if buffl
                        el = buffl
                    elsif buffc
                        ec = buffc
                    # by default, insert a new lead
                    else
                        el = Mass::Lead.find(i)
                        # if I didn't find the lead, try to find it using the email_2 field.
                        if el.nil?
                            el = Mass::Lead.where(
                                Sequel.lit("
                                    id_account = '#{i['id_account']}' AND
                                    delete_time IS NULL AND
                                    email_2 IS NOT NULL AND
                                    email_2 = '#{i['email'].to_s.to_sql}'
                                ")
                            ).first
                        end # el.nil?
                    end
                end # if h['lead'] || h['company'] || h['lead_or_company']
            end # if h['profile']['leased']

            # if direction is :incoming or :accept, find another outreach where its
            # message_id matches with the reply_to_message_id if this, and copy the lead and company.
            if el.nil? && ec.nil?
                if h['direction'].to_sym == :incoming || h['direction'].to_sym == :accepted
                    if h['reply_to_message_id']
                        o2 = self.where(:id_account => h['id_account'], :message_id => h['reply_to_message_id']).first
                        if o2
                            if o2.lead
                                el = o2.lead
                                el.email_2 = h['lead']['email'] if h['lead'] && h['lead']['email']
                                el.email_2 = h['lead_or_company']['email'] if h['lead_or_company'] && h['lead_or_company']['email']
                                el.save
                            elsif o2.company
                                ec = o2.company
                                ec.email_2 = h['company']['email'] if h['company'] && h['company']['email']
                                ec.email_2 = h['lead_or_company']['email'] if h['lead_or_company'] && h['lead_or_company']['email']
                                ec.save
                            end
                        end
                    end # if h['reply_to_message_id']
                end # if h['direction'] == :incoming || h['direction'] == :accepted
            end # if e.nil?

            # only register the outreach if the lead or company exists in the CRM, or if the profile is not leased (so the account can ingest all the inbox)
            if el || ec || !h['profile']['leased']
                h['lead'] ||= el.to_h if el
                h['company'] ||= ec.to_h if ec
                h['lead_or_company'] = nil if el || ec
                o = self.find(h)
                b = upsert_children
                if o.nil?
                    o = self.insert(h, upsert_children: b)
                else
                    o.update(h, upsert_children: b)
                end

                return o
            else
                return nil
            end
        end # def self.upsert

        # insert or update a record
        # return the object
        def update(h={}, upsert_children: true)
            b = upsert_children
            o = base_update(h, upsert_children: b, manage_done_time: true)

            o.status = self.class.status_code(h['status']) if h['status']
            o.direction = self.class.direction_code(h['direction']) if h['direction']
            o.approved = h['approved'] if h['approved']
            o.skip_repliers = self.class.skip_code(h['skip_repliers']) if !h['skip_repliers'].nil?
            o.id_tag = Mass::Tag.where(:id_account => h['id_account'], :name => h['tag'].to_s).first.id if h['tag']

            o.subject = h['subject'] if h['subject']
            o.body = h['body'] if h['body']
            o.body_type = self.class.body_code(h['body_type']) if h['body_type']
            o.error_description = h['error_description'] if h['error_description']
            o.screenshot_url = h['screenshot_url'] if !h['screenshot_url'].nil?
            o.snapshot_url = h['snapshot_url'] if !h['snapshot_url'].nil?

            o.message_id = h['message_id'] if h['message_id']
            o.reply_to_message_id = h['reply_to_message_id'] if h['reply_to_message_id']

            o.id_outreach_type = Mass::OutreachType.where(:id_account => h['id_account'], :name => h['outreach_type'].to_s).first.id if h['outreach_type']
            #if h['outreach_type']
            #    i = h['outreach_type']
            #    i['id_account'] = h['id_account']
            #    i['id_user'] = h['id_user']
            #    e = Mass::OutreachType.upsert(i) 
            #    o.id_outreach_type = e.id
            #end

            # 
            if h['lead']
                i = h['lead']
                i['id_account'] = h['id_account']
                i['id_user'] = h['id_user']
                e = Mass::Lead.upsert(i)
                o.id_lead = e.id
            elsif h['company']
                i = h['company']
                i['id_account'] = h['id_account']
                i['id_user'] = h['id_user']
                e = Mass::Company.upsert(i)
                o.id_company = e.id
            elsif h['lead_or_company']
                i = h['lead_or_company']
                i['id_account'] = h['id_account']
                i['id_user'] = h['id_user']

                ret1 = Mass::Lead::errors(i) 
                ret2 = Mass::Company::errors(i) 

                buffl = nil
                buffc = nil

                if ret1.size == 0
                    buffl = Mass::Lead.where(:id_account=>self.id_account, :delete_time=>nil, :email=>i['email']).first
                end

                if ret2.size == 0 && buffl.nil?
                    buffc = Mass::Company.where(:id_account=>self.id_account, :delete_time=>nil, :email=>i['email']).first
                end

                if buffl
                    buffl.update(i)
                    o.id_lead = buffl.id
                elsif buffc
                    e = buffc.update(i)
                    o.id_company = buffc.id
                # by default, insert a new lead
                else
                    e = Mass::Lead.upsert(i)
                    o.id_lead = e.id
                end
            end

            if h['profile']
                i = h['profile']
                i['id_account'] = h['id_account']
                i['id_user'] = h['id_user']
                e = Mass::Profile.upsert(i)
                o.id_profile = e.id
            end

            o.save

            return o
        end # def update

        def to_h(preview: false)
            ret = self.to_h_base
            ret['status'] = self.class.statuses[self.status] if !self.status.nil?
            ret['tag'] = self.tag.name.to_s if !self.tag.nil?
            ret['approved'] = self.approved if !self.approved.nil?
            ret['skip_repliers'] = self.class.skips[self.skip_repliers] if !self.skip_repliers.nil?
            ret['direction'] = self.class.directions[self.direction] if !self.direction.nil?
            ret['error_description'] = self.error_description if self.error_description
            ret['screenshot_url'] = self.screenshot_url if !self.screenshot_url.nil?
            ret['snapshot_url'] = self.snapshot_url if !self.snapshot_url.nil?
            ret['outreach_type'] = self.outreach_type.name.to_sym if !self.outreach_type.nil?
            ret['outreach_type_desc'] = self.outreach_type.to_h if !self.outreach_type.nil?
            ret['lead'] = self.lead.to_h if !self.lead.nil?
            ret['company'] = self.company.to_h if !self.company.nil?
            ret['profile'] = self.profile.to_h if !self.profile.nil?
            ret['subject'] = self.subject if !self.subject.nil?
            ret['body'] = self.body if !self.body.nil?
            ret['body_type'] = self.class.bodies[self.body_type] if !self.body_type.nil?
            ret['message_id'] = self.message_id if !self.message_id.nil?
            ret['reply_to_message_id'] = self.reply_to_message_id if !self.reply_to_message_id.nil?
            # dummy parameters, not stored in the database by returned by to_h method
            ret['merged_subject'] = self.merged_subject
            ret['merged_body'] = self.merged_body(preview: preview)

            doc = Nokogiri::HTML(self.merged_body(preview: preview))
            doc.search('a').each{ |a| 
                if a.text == a['href']
                    a.replace "#{a['href']}" 
                else
                    a.replace "#{a.text}: #{a['href']}" 
                end
            }
            doc.search('p,div,br').each{ |e| e.after "\n" }

            ret['merged_body_text'] = doc.text
            ret['action'] = self.action.nil? ? nil : self.action.to_h

            # return
            return ret
        end # def to_h

        # iterate pendings outreaches, with no profile assigned, not deleted and belonging to an active outreach
        def self.assign(logger:nil)
            l = logger || BlackStack::DummyLogger.new(nil)
            l.log "Starting outreach assignation.".blue
            q = "
            SELECT e.id
            FROM \"outreach\" e

            -- job cannot be assigned to a basic-access profile
            JOIN \"outreach_type\" et ON e.id_outreach_type=et.id
            JOIN \"profile_type\" pt ON (et.id_profile_type=pt.id AND pt.access<>#{Mass::ProfileType.access_code(:basic)})

            -- job must be pending, approved, with no profile assigned and not deleted
            WHERE e.status=#{status_code(:pending)} 
            AND e.direction=#{direction_code(:outgoing)}
            AND e.approved = true
            AND e.id_profile IS NULL
            AND e.delete_time IS NULL
            ORDER BY e.create_time
            "

            DB[q].all { |row|
                l.logs "Assigning outreach #{row[:id].blue}... "
                Mass::Outreach.where(:id => row[:id]).first.assign(logger: l)
                l.logf 'done'.green
            }
        end # def self.assign

        # iterate profiles:
        # - belonging to the same account, 
        # - not deleted, 
        # - not in :idle state, 
        # - belonging to the same tag than the outreach,
        # - belonging to the same profile_type than the outreach.
        # 
        # Get the list of profiles of this account, belonging to the same channel than the outreach.
        #
        # If the lead/company has been already reached by another outreach with the same profile_type, 
        # and the profile of that previous outreach is not deleted, and it is still belonging to this account,
        # then this is the only profile that can be assigned to this outreach.
        #
        # if the profile is assigned to any pending outreach, skip it.
        # if the profile is assigned to a outreach with a done_time within the last outreaches_interval of the profile, skip it.
        # if the number of outreaches with done_time within the last day and status == :performed or :failed or :running, is higher than the max_daily_processed_outreaches of the profile, skip it.
        # if the number of outreaches with done_time within the last day and status == :aborted is higher than the max_daily_aborted_outreaches of the profile, skip it.
        # else, assign the profile to the job and exit the look. 
        # 
        def assign(logger:nil)
            l = logger || BlackStack::DummyLogger.new(nil)
            support_state = self.outreach_type.profile_type.rpa?

            # If the lead/company has been already reached by another outreach with the same profile_type, 
            # and the profile of that previous outreach is not deleted, and it is still belonging to this account,
            # then this is the only profile that can be assigned to this outreach.
            pt = self.outreach_type.profile_type
            prev_profile = nil
            if self.lead
                q = "
                    select o.id, o.id_profile, o.status
                    from outreach o
                    join outreach_type ot on ot.id=o.id_outreach_type
                    where ot.id_profile_type = '#{pt.id}'
                    and o.status = #{self.class.status_code(:performed)}
                    and o.direction = #{self.class.direction_code(:outgoing)}
                    and o.id_account = '#{self.id_account}'
                "

                q += "
                    and o.id_lead = '#{self.id_lead}'
                " if self.id_lead

                q += "
                    and o.id_company = '#{self.id_company}'
                " if self.id_company

                q += "
                    order by o.done_time desc
                "

                row = DB.fetch(q).first
                if row
                    pid =  row[:id_profile]
                    prev_profile = Mass::Profile.where(:id=>pid, :id_account=>self.id_account, :delete_time=>nil).first if pid
                else
                    prev_profile = nil
                end
            end

            # iterate profiles:
            # - belonging to the same account, 
            # - not deleted, 
            # - not in :idle state, 
            # - belonging to the same tag than the outreach,
            # - belonging to the same profile_type than the outreach_type of the outreach.
            q = "
            SELECT p.id
            FROM \"profile\" p
            JOIN \"account\" a ON (p.id_account=a.id AND a.id='#{self.id_account}')
            WHERE p.delete_time IS NULL 
            #{support_state ? "AND p.state<>#{self.class.state_code(:idle)}" : ''}
            AND p.id_profile_type = '#{self.outreach_type.id_profile_type}'
            "

            q += "
            AND p.id = '#{prev_profile.id}'
            " if prev_profile

            q += "
            AND p.id_tag = '#{self.id_tag}'            
            " if self.id_tag

            DB[q].all { |row|
                l.logs "Profile (#{row[:id].blue})... "
                p = Mass::Profile.where(:id => row[:id]).first

                # if the profile is assigned to any pending job, skip it.
                n = Mass::Outreach.where(
                    :id_profile => p.id,
                    :status => self.class.status_code(:pending),
                    :direction => self.class.direction_code(:outgoing),
                    :approved => true
                ).count
                if n > 0
                    l.logf 'skip'.yellow + ' (already assigned to pending Outreach)'
                    next
                end

                # if the profile is assigned to a outreach with a done_time within the last outreaches_interval of the profile, skip it.
                n = Mass::Outreach.where(
                    Sequel.lit("
                        id_profile='#{p.id}' AND
                        status <> #{self.class.status_code(:pending)} AND
                        COALESCE(done_time, CAST('1900-01-01' AS TIMESTAMP)) > CAST('#{now}' AS TIMESTAMP) - INTERVAL '#{p.outreach_interval.to_s} seconds'
                    ")
                ).count
                if n > 0
                    l.logf 'skip'.yellow + " (already perfomed an outreach within the last #{p.outreach_interval.to_s} seconds)"
                    next
                end

                # if the number of outreaches with done_time within the last day and status == :performed or :failed or :running, is higher than the max_daily_processed_outreaches of the profile, skip it.
                n = Mass::Outreach.where(
                    Sequel.lit("
                        id_profile='#{p.id}' AND
                        status IN (#{self.class.status_code(:running)}, #{self.class.status_code(:performed)}, #{self.class.status_code(:failed)}) AND
                        COALESCE(done_time, CAST('1900-01-01' AS TIMESTAMP)) > CAST('#{now}' AS TIMESTAMP) - INTERVAL '24 HOURS'
                    ")
                ).count
                if n > p.max_daily_processed_outreaches
                    l.logf 'skip'.yellow + ' (daily outreaches quota)'
                    next
                end

                # if the number of outreaches with done_time within the last day and status <> :pending is higher than the max_daily_processed_outreaches of the profile, skip it.
                n = Mass::Outreach.where(
                    Sequel.lit("
                        id_profile='#{p.id}' AND
                        status <> #{self.class.status_code(:pending)} AND
                        status <> #{self.class.status_code(:aborted)} AND
                        COALESCE(done_time, CAST('1900-01-01' AS TIMESTAMP)) > CAST('#{now}' AS TIMESTAMP) - INTERVAL '24 HOURS'
                    ")
                ).count
                if n > p.max_daily_processed_outreaches
                    l.logf 'skip'.yellow + ' (daily processed outreaches quota)'
                    next
                end

                # if the number of outreaches with done_time within the last day and status == :aborted is higher than the max_daily_aborted_outreaches of the profile, skip it.
                n = Mass::Outreach.where(
                    Sequel.lit("
                        id_profile='#{p.id}' AND
                        status = #{self.class.status_code(:aborted)} AND
                        COALESCE(done_time, CAST('1900-01-01' AS TIMESTAMP)) > CAST('#{now}' AS TIMESTAMP) - INTERVAL '24 HOURS'
                    ")
                ).count
                if n > p.max_daily_aborted_outreaches
                    l.logf 'skip'.yellow + ' (daily aborted outreaches quota)'
                    next
                end

                # else, assign the profile to the job
                self.id_profile = p.id
                self.save
                l.logf 'done'.green

                # and exit the look
                break
            }
        end # def assign

        # apply this outreach to the timeline
        def timeline(logger:nil)
            l = logger || BlackStack::DummyLogger.new(nil)
        end # def timeline

        # pass this outreach through all the active rules beloning to the same account
        def rule(logger:nil)
            l = logger || BlackStack::DummyLogger.new(nil)
            e = this
            Mass::Rule.where(
                :id_account => e.id_account, 
                :active => true, 
                :delete_time => nil
            ).each { |r|
                # iterate all the :event_created triggers belonging to the rule, linked to the same source than the event.
                l.logs "Rule: #{r.name.blue}... "
                Mass::Trigger.where(
                    :id_account => r.id_account, 
                    :id_rule => r.id, 
                    :trigger => [Mass::Trigger.trigger_code(:outreach_done), Mass::Trigger.trigger_code(:accepted_request)],
                    :id_outreach => e.id_outreach,
                    :delete_time => nil
                ).each { |t|
                    # 
                    l.logs "Trigger: #{t.name.blue}... "

                    # pass the events, lead, company through the filters.
                    passed = true
                    Mass::Filter.where(
                        :id_account => r.id_account,
                        :id_rule => r.id,
                        :delete_time => nil
                    ).each { |f|
                        l.logs "Filter: #{f.name.blue}... "
                        if f.type == Mass::Filter.filter_code(:lead) && e.lead
                            passed &&= f.test_lead(e.lead)
                        elsif f.type == Mass::Filter.filter_code(:company) && e.company
                            passed &&= f.test_company(e.company)
                        #elsif f.type == Mass::Filter.filter_code(:event) && e
                        #    passed &&= f.test_event(e)
                        end
                        if passed
                            l.logs "passed".green
                        else
                            l.logs "failed".yellow
                            break
                        end
                    } # each filter
                    l.logf 'done'.green

                    if passed
                        # pass the event through the actions
                        Mass::Action.where(
                            :id_account => r.id_account,
                            :id_rule => r.id,
                            :delete_time => nil
                        ).each { |a|
                            l.logs "Action: #{a.name.blue}... "
                            a.schedule(
                                delay_number: r.delay_number,
                                delay_unit: Mass::Rule.units[r.delay_unit],
                                lead: e.lead,
                                company: e.company #,
                                #tag: e.tag,
                            )
                            l.logf 'done'.green
                        } # each action        
                    end
                } # each trigger
                l.logf 'done'.green
            } # each rule    
        end # def rule

        # get the latest and performed :outgoing outreach for the same profile and the same lead or company, sorting by the create_time field.
        # raise an exception if the outreach has no lead and no company.
        # raise an exception if no profile is assigned to this outreach.
        def prev
            id_profile = self.id_profile
            id_lead = self.id_lead
            id_company = self.id_company
            raise "Cannot get previous outreach because no lead and no company are assigned to this outreach." if id_lead.nil? && id_company.nil?
            raise "Cannot get previous outreach because no profile is assigned to this outreach." if id_profile.nil?
            q = "
                select o.id
                from \"outreach\" o
                where o.id_profile = '#{id_profile}'
                and o.status = #{Mass::Outreach.status_code(:performed)}
                and o.direction = #{Mass::Outreach.direction_code(:outgoing)}
                and o.create_time < CAST('#{self.create_time}' AS TIMESTAMP)
            "
            q += "
                and o.id_lead = '#{id_lead}'
            " if id_lead
            q += "
                and o.id_company = '#{id_company}'
            " if id_company
            q += "
                order by o.create_time desc
            "
            row = DB[q].first
            return nil if row.nil?
            return Mass::Outreach.where(:id => row[:id]).first
        end

        # timeline summarization methods
        #

        # - raise an exception if the direction of this is not :incoming or :accepted
        # - (DEPRECATED) raise an exception if there is not a last outgoing outreach from the same 
        #   profile to the same lead/company, and generated from an action.
        #
        # return true if:
        # 
        # 1. there is not any previous outreach,
        # 2. in the same direction between the same profile and lead/company,
        # 3. after the last outgoing outreach from the same profile to the 
        #    same lead/company, and generated from an action.
        # 
        def first_time?
            raise "Cannot check if it is the first time if the direction is not :incoming or :accepted." if self.direction != self.class.direction_code(:incoming) && self.direction != self.class.direction_code(:accepted)

            # last outgoing outreach from the same profile to the same lead/company.
            last_outgoing_outreach = Mass::Outreach.where(
                :id_account => self.id_account,
                :id_profile => self.id_profile,
                :id_lead => self.id_lead,
                :id_company => self.id_company,
                :direction => self.class.direction_code(:outgoing),
                :status => self.class.status_code(:performed),
            ).where(
                Sequel.lit("
                    done_time is not null and
                    id_action is not null
                ")
            ).order(:done_time).last

            # This validation is removed because of the following situation:
            # 1. you add a lead to your CRM with who you already had a conversation in the past.
            # 2. such a new conversation with the lead is scraped, and the first message in the conversation is :incoming.
            #
            #raise "The :incoming or :accepted outreach (#{self.id}) has not a previous :outgoing message generated by a rule." if last_outgoing_outreach.nil?

            q = "
                SELECT o.id
                FROM \"outreach\" o
                WHERE o.id_account = '#{self.id_account}'
                AND o.direction = #{self.direction}
                AND o.id_outreach_type = '#{self.id_outreach_type}'
                AND o.id_profile = '#{self.id_profile}'
            "

            q += "
                AND COALESCE(o.id_lead, 'b08d6d8b-5e0f-40a2-966c-673eb409ef34') = '#{self.id_lead}'
            " if self.id_lead

            q += "
                AND COALESCE(o.id_company, 'b08d6d8b-5e0f-40a2-966c-673eb409ef34') = '#{self.id_company}'
            " if self.id_company

            q += "
                AND o.create_time > CAST('#{last_outgoing_outreach.done_time}' AS TIMESTAMP)
            " if last_outgoing_outreach

            q += "
                AND o.create_time < CAST('#{self.create_time}' AS TIMESTAMP)
            "

            DB[q].first.nil?
        end

        # if the direction is :outgoing, 
        # - load the action that created this outreach
        # - find a record with the same id_outreach_type and id_action, and a null value in the field id_profile.
        # - if the record doesn't exists, then create it.
        # - increase the counter outreaches_created in such a record.
        #
        # update the flag summarize_creation_to_timeline of this object.
        def summarize_creation_to_timeline
            # if the direction is :outgoing, 
            if self.direction == self.class.direction_code(:outgoing)
                # load the action that created this outreach
                a = self.action
                # find a record with the same id_outreach_type and id_action, and a null value in the field id_profile.
                t = Mass::Timeline.where(
                    Sequel.lit("
                        id_outreach_type = '#{self.id_outreach_type}' AND
                        id_profile IS NULL AND
                        id_action = #{ a.nil? ? "NULL" : "'#{a.id}'" } AND
                        \"year\" = #{self.create_time.year} AND
                        \"month\" = #{self.create_time.month} AND
                        \"day\" = #{self.create_time.day} AND
                        \"hour\" = #{self.create_time.hour} AND
                        \"minute\" = #{self.create_time.min}
                    ")
                ).first
                # if the record doesn't exists, then create it.
                if t.nil?
                    t = Mass::Timeline.new
                    t.id = guid
                    t.id_account = self.id_account
                    t.create_time = now
                    t.id_outreach_type = self.id_outreach_type
                    t.id_profile = nil
                    t.id_action = a.id if a
                    t.year = self.create_time.year
                    t.month = self.create_time.month
                    t.day = self.create_time.day
                    t.hour = self.create_time.hour
                    t.minute = self.create_time.min
                    t.dt = self.create_time
                end
            elsif self.direction == self.class.direction_code(:incoming) || self.direction == self.class.direction_code(:accepted)
                # load previous :performed and :outgoing outreach
                o = self.prev
                # load action that created the previous outreach
                a = o.nil? ? nil : o.action
                # find a record with the same id_outreach_type and id_profile.
                t = Mass::Timeline.where(
                    Sequel.lit("
                        id_outreach_type = '#{self.id_outreach_type}' AND
                        id_profile = '#{self.id_profile}' AND
                        id_action #{ a.nil? ? "IS NULL" : "= '#{a.id}'" } AND
                        \"year\" = #{self.create_time.year} AND
                        \"month\" = #{self.create_time.month} AND
                        \"day\" = #{self.create_time.day} AND
                        \"hour\" = #{self.create_time.hour} AND
                        \"minute\" = #{self.create_time.min}
                    ")
                ).first
                # if the record doesn't exists, then create it.
                if t.nil?
                    t = Mass::Timeline.new
                    t.id = guid
                    t.id_account = self.id_account
                    t.create_time = now
                    t.id_outreach_type = self.id_outreach_type
                    t.id_profile = self.id_profile
                    t.id_action = a.id if a
                    t.year = self.create_time.year
                    t.month = self.create_time.month
                    t.day = self.create_time.day
                    t.hour = self.create_time.hour
                    t.minute = self.create_time.min
                    t.dt = self.create_time
                end
            end # if self.direction == self.class.direction_code(:outgoing)

            if t
                # if the direction is :outgoing, then increase the counter outreaches_created in the record.
                if self.direction == self.class.direction_code(:outgoing)
                    t.outreaches_created = t.outreaches_created.to_i + 1 
                end # if self.direction == self.class.direction_code(:outgoing)

                # if the direction is :incoming, then increase the counter outreaches_replied in the record.
                if self.direction == self.class.direction_code(:incoming)
                    t.outreaches_replied = t.outreaches_replied.to_i + 1
                    t.outreaches_unique_replied = t.outreaches_unique_replied.to_i + 1 if first_time?
                end # if self.direction == self.class.direction_code(:incoming)

                # if the direction is :accepted, then increase the counter outreaches_accepted in the record.
                if self.direction == self.class.direction_code(:accepted)
                    t.outreaches_accepted = t.outreaches_accepted.to_i + 1
                    t.outreaches_unique_accepted = t.outreaches_unique_accepted.to_i + 1 if first_time?
                end # if self.direction == self.class.direction_code(:accepted)

                t.save
            end

            # update the flag summarize_creation_to_timeline of this object.
            self.summarize_creation_to_timeline = now
            self.save
        end # summarize_creation_to_timeline

        # raise an exception of id_profile is null.
        # raise an exception of done_time is null.
        # load the action that created this outreach
        # find a record with the same id_outreach_type and id_profile.
        # if trhe record doesn't exists, then create it.
        # if the direction is :outgoing and the status is :performed, then increase the counter outreaches_performed in the record.
        # if the direction is :outgoing and the status is :failed, then increase the counter outreaches_failed in the record.
        # if the direction is :outgoing and the status is :aborted, then increase the counter outreaches_aborted in the record.
        # save the timeline record.
        # update the flag summarize_status_to_timeline of this object.
        def summarize_status_to_timeline
            if self.direction == self.class.direction_code(:outgoing)
                # raise an exception of id_profile is null.
                #raise 'id_profile cannot be null' if self.id_profile.nil?
                # raise an exception of done_time is null.
                raise 'done_time cannot be null' if self.done_time.nil?
                # load the action that created this outreach
                a = self.action
                # find a record with the same id_outreach_type and id_profile.
                t = Mass::Timeline.where(
                    Sequel.lit("
                        id_outreach_type = '#{self.id_outreach_type}' AND
                        id_profile #{ id_profile.nil? ? "IS NULL" : "= '#{id_profile}'" } AND
                        id_action #{ a.nil? ? "IS NULL" : "= '#{a.id}'" } AND
                        \"year\" = #{self.done_time.year} AND
                        \"month\" = #{self.done_time.month} AND
                        \"day\" = #{self.done_time.day} AND
                        \"hour\" = #{self.done_time.hour} AND
                        \"minute\" = #{self.done_time.min}
                    ")
                ).first
                # if the record doesn't exists, then create it.
                if t.nil?
                    t = Mass::Timeline.new
                    t.id = guid
                    t.id_account = self.id_account
                    t.create_time = now
                    t.id_outreach_type = self.id_outreach_type
                    t.id_profile = self.id_profile
                    t.id_action = a.id if a
                    t.year = self.done_time.year
                    t.month = self.done_time.month
                    t.day = self.done_time.day
                    t.hour = self.done_time.hour
                    t.minute = self.done_time.min
                    t.dt = self.done_time
                end

                # if the status is :performed, then increase the counter outreaches_performed in the record.
                t.outreaches_performed = t.outreaches_performed.to_i + 1 if self.status == self.class.status_code(:performed)
                # if the status is :failed, then increase the counter outreaches_failed in the record.
                t.outreaches_failed = t.outreaches_failed.to_i + 1 if self.status == self.class.status_code(:failed)
                # if the status is :aborted, then increase the counter outreaches_aborted in the record.
                t.outreaches_aborted = t.outreaches_aborted.to_i + 1 if self.status == self.class.status_code(:aborted)
                # and save the timeline record.
                t.save
            end

            # update the flag summarize_status_to_timeline of this object.
            self.summarize_status_to_timeline = now
            self.save
        end # def summarize_status_to_timeline

    end # class Outreach
end # module Mass

Please privide the following Ruby code snippets to add to mass-copilot.rb:

  1. a hash descriptor of a function create_outreach to add to the openai_client.
  2. a method create_outreach