cantino/huginn

View on GitHub
app/models/agents/trigger_agent.rb

Summary

Maintainability
A
0 mins
Test Coverage
module Agents
  class TriggerAgent < Agent
    cannot_be_scheduled!
    can_dry_run!

    VALID_COMPARISON_TYPES = %w[
      regex
      !regex
      field<value
      field<=value
      field==value
      field!=value
      field>=value
      field>value
      not\ in
    ]

    description <<~MD
      The Trigger Agent will watch for a specific value in an Event payload.

      The `rules` array contains a mixture of strings and hashes.

      A string rule is a Liquid template and counts as a match when it expands to `true`.

      A hash rule consists of the following keys: `path`, `value`, and `type`.

      The `path` value is a dotted path through a hash in [JSONPaths](http://goessner.net/articles/JsonPath/) syntax. For simple events, this is usually just the name of the field you want, like 'text' for the text key of the event.

      The `type` can be one of #{VALID_COMPARISON_TYPES.map { |t| "`#{t}`" }.to_sentence} and compares with the `value`.  Note that regex patterns are matched case insensitively.  If you want case sensitive matching, prefix your pattern with `(?-i)`.

      In any `type` including regex Liquid variables can be used normally. To search for just a word matching the concatenation of `foo` and variable `bar` would use `value` of `foo{{bar}}`. Note that note that starting/ending delimiters like `/` or `|` are not required for regex.

      The `value` can be a single value or an array of values. In the case of an array, all items must be strings, and if one or more values match, then the rule matches. Note: avoid using `field!=value` with arrays, you should use `not in` instead.

      By default, all rules must match for the Agent to trigger. You can switch this so that only one rule must match by
      setting `must_match` to `1`.

      The resulting Event will have a payload message of `message`.  You can use liquid templating in the `message, have a look at the [Wiki](https://github.com/huginn/huginn/wiki/Formatting-Events-using-Liquid) for details.

      Set `keep_event` to `true` if you'd like to re-emit the incoming event, optionally merged with 'message' when provided.

      Set `expected_receive_period_in_days` to the maximum amount of time that you'd expect to pass between Events being received by this Agent.
    MD

    event_description <<~MD
      Events look like this:

          { "message": "Your message" }
    MD

    private def valid_rule?(rule)
      case rule
      when String
        true
      when Hash
        VALID_COMPARISON_TYPES.include?(rule['type']) &&
          /\S/.match?(rule['path']) &&
          rule.key?('value')
      else
        false
      end
    end

    def validate_options
      unless options['expected_receive_period_in_days'].present? &&
          options['rules'].present? &&
          options['rules'].all? { |rule| valid_rule?(rule) }
        errors.add(:base,
                   "expected_receive_period_in_days, message, and rules, with a type, value, and path for every rule, are required")
      end

      errors.add(:base,
                 "message is required unless 'keep_event' is 'true'") unless options['message'].present? || keep_event?

      errors.add(:base,
                 "keep_event, when present, must be 'true' or 'false'") unless options['keep_event'].blank? || %w[
                   true false
                 ].include?(options['keep_event'])

      if options['must_match'].present?
        if options['must_match'].to_i < 1
          errors.add(:base, "If used, the 'must_match' option must be a positive integer")
        elsif options['must_match'].to_i > options['rules'].length
          errors.add(:base, "If used, the 'must_match' option must be equal to or less than the number of rules")
        end
      end
    end

    def default_options
      {
        'expected_receive_period_in_days' => "2",
        'keep_event' => 'false',
        'rules' => [{
          'type' => "regex",
          'value' => "foo\\d+bar",
          'path' => "topkey.subkey.subkey.goal",
        }],
        'message' => "Looks like your pattern matched in '{{value}}'!"
      }
    end

    def working?
      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
    end

    def receive(incoming_events)
      incoming_events.each do |event|
        opts = interpolated(event)

        match_results = opts['rules'].map do |rule|
          if rule.is_a?(String)
            next boolify(rule)
          end

          value_at_path = Utils.value_at(event['payload'], rule['path'])
          rule_values = rule['value']
          rule_values = [rule_values] unless rule_values.is_a?(Array)

          if rule['type'] == 'not in'
            !rule_values.include?(value_at_path.to_s)
          elsif rule['type'] == 'field==value'
            rule_values.include?(value_at_path.to_s)
          else
            rule_values.any? do |rule_value|
              case rule['type']
              when "regex"
                value_at_path.to_s =~ Regexp.new(rule_value, Regexp::IGNORECASE)
              when "!regex"
                value_at_path.to_s !~ Regexp.new(rule_value, Regexp::IGNORECASE)
              when "field>value"
                value_at_path.to_f > rule_value.to_f
              when "field>=value"
                value_at_path.to_f >= rule_value.to_f
              when "field<value"
                value_at_path.to_f < rule_value.to_f
              when "field<=value"
                value_at_path.to_f <= rule_value.to_f
              when "field!=value"
                value_at_path.to_s != rule_value.to_s
              else
                raise "Invalid type of #{rule['type']} in TriggerAgent##{id}"
              end
            end
          end
        end

        next unless matches?(match_results)

        if keep_event?
          payload = event.payload.dup
          payload['message'] = opts['message'] if opts['message'].present?
        else
          payload = { 'message' => opts['message'] }
        end

        create_event(payload:)
      end
    end

    def matches?(matches)
      if options['must_match'].present?
        matches.select { |match| match }.length >= options['must_match'].to_i
      else
        matches.all?
      end
    end

    def keep_event?
      boolify(interpolated['keep_event'])
    end
  end
end