trigger_agent.rb 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. module Agents
  2. class TriggerAgent < Agent
  3. cannot_be_scheduled!
  4. can_dry_run!
  5. VALID_COMPARISON_TYPES = %w[
  6. regex
  7. !regex
  8. field<value
  9. field<=value
  10. field==value
  11. field!=value
  12. field>=value
  13. field>value
  14. not\ in
  15. ]
  16. description <<~MD
  17. The Trigger Agent will watch for a specific value in an Event payload.
  18. The `rules` array contains a mixture of strings and hashes.
  19. A string rule is a Liquid template and counts as a match when it expands to `true`.
  20. A hash rule consists of the following keys: `path`, `value`, and `type`.
  21. 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.
  22. 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)`.
  23. 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.
  24. 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.
  25. By default, all rules must match for the Agent to trigger. You can switch this so that only one rule must match by
  26. setting `must_match` to `1`.
  27. 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.
  28. Set `keep_event` to `true` if you'd like to re-emit the incoming event, optionally merged with 'message' when provided.
  29. 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.
  30. MD
  31. event_description <<~MD
  32. Events look like this:
  33. { "message": "Your message" }
  34. MD
  35. private def valid_rule?(rule)
  36. case rule
  37. when String
  38. true
  39. when Hash
  40. VALID_COMPARISON_TYPES.include?(rule['type']) &&
  41. /\S/.match?(rule['path']) &&
  42. rule.key?('value')
  43. else
  44. false
  45. end
  46. end
  47. def validate_options
  48. unless options['expected_receive_period_in_days'].present? &&
  49. options['rules'].present? &&
  50. options['rules'].all? { |rule| valid_rule?(rule) }
  51. errors.add(:base,
  52. "expected_receive_period_in_days, message, and rules, with a type, value, and path for every rule, are required")
  53. end
  54. errors.add(:base,
  55. "message is required unless 'keep_event' is 'true'") unless options['message'].present? || keep_event?
  56. errors.add(:base,
  57. "keep_event, when present, must be 'true' or 'false'") unless options['keep_event'].blank? || %w[
  58. true false
  59. ].include?(options['keep_event'])
  60. if options['must_match'].present?
  61. if options['must_match'].to_i < 1
  62. errors.add(:base, "If used, the 'must_match' option must be a positive integer")
  63. elsif options['must_match'].to_i > options['rules'].length
  64. errors.add(:base, "If used, the 'must_match' option must be equal to or less than the number of rules")
  65. end
  66. end
  67. end
  68. def default_options
  69. {
  70. 'expected_receive_period_in_days' => "2",
  71. 'keep_event' => 'false',
  72. 'rules' => [{
  73. 'type' => "regex",
  74. 'value' => "foo\\d+bar",
  75. 'path' => "topkey.subkey.subkey.goal",
  76. }],
  77. 'message' => "Looks like your pattern matched in '{{value}}'!"
  78. }
  79. end
  80. def working?
  81. last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
  82. end
  83. def receive(incoming_events)
  84. incoming_events.each do |event|
  85. opts = interpolated(event)
  86. match_results = opts['rules'].map do |rule|
  87. if rule.is_a?(String)
  88. next boolify(rule)
  89. end
  90. value_at_path = Utils.value_at(event['payload'], rule['path'])
  91. rule_values = rule['value']
  92. rule_values = [rule_values] unless rule_values.is_a?(Array)
  93. if rule['type'] == 'not in'
  94. !rule_values.include?(value_at_path.to_s)
  95. elsif rule['type'] == 'field==value'
  96. rule_values.include?(value_at_path.to_s)
  97. else
  98. rule_values.any? do |rule_value|
  99. case rule['type']
  100. when "regex"
  101. value_at_path.to_s =~ Regexp.new(rule_value, Regexp::IGNORECASE)
  102. when "!regex"
  103. value_at_path.to_s !~ Regexp.new(rule_value, Regexp::IGNORECASE)
  104. when "field>value"
  105. value_at_path.to_f > rule_value.to_f
  106. when "field>=value"
  107. value_at_path.to_f >= rule_value.to_f
  108. when "field<value"
  109. value_at_path.to_f < rule_value.to_f
  110. when "field<=value"
  111. value_at_path.to_f <= rule_value.to_f
  112. when "field!=value"
  113. value_at_path.to_s != rule_value.to_s
  114. else
  115. raise "Invalid type of #{rule['type']} in TriggerAgent##{id}"
  116. end
  117. end
  118. end
  119. end
  120. next unless matches?(match_results)
  121. if keep_event?
  122. payload = event.payload.dup
  123. payload['message'] = opts['message'] if opts['message'].present?
  124. else
  125. payload = { 'message' => opts['message'] }
  126. end
  127. create_event(payload:)
  128. end
  129. end
  130. def matches?(matches)
  131. if options['must_match'].present?
  132. matches.select { |match| match }.length >= options['must_match'].to_i
  133. else
  134. matches.all?
  135. end
  136. end
  137. def keep_event?
  138. boolify(interpolated['keep_event'])
  139. end
  140. end
  141. end