1
0

event_formatting_agent.rb 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. module Agents
  2. class EventFormattingAgent < Agent
  3. cannot_be_scheduled!
  4. can_dry_run!
  5. description <<~MD
  6. The Event Formatting Agent allows you to format incoming Events, adding new fields as needed.
  7. For example, here is a possible Event:
  8. {
  9. "high": {
  10. "celsius": "18",
  11. "fahreinheit": "64"
  12. },
  13. "date": {
  14. "epoch": "1357959600",
  15. "pretty": "10:00 PM EST on January 11, 2013"
  16. },
  17. "conditions": "Rain showers",
  18. "data": "This is some data"
  19. }
  20. You may want to send this event to another Agent, for example a Twilio Agent, which expects a `message` key.
  21. You can use an Event Formatting Agent's `instructions` setting to do this in the following way:
  22. "instructions": {
  23. "message": "Today's conditions look like {{conditions}} with a high temperature of {{high.celsius}} degrees Celsius.",
  24. "subject": "{{data}}",
  25. "created_at": "{{created_at}}"
  26. }
  27. Names here like `conditions`, `high` and `data` refer to the corresponding values in the Event hash.
  28. The special key `created_at` refers to the timestamp of the Event, which can be reformatted by the `date` filter, like `{{created_at | date:"at %I:%M %p" }}`.
  29. The upstream agent of each received event is accessible via the key `agent`, which has the following attributes: #{''.tap { |s| s << Agent::Drop.instance_methods(false).map { |m| "`#{m}`" }.join(', ') }}.
  30. Have a look at the [Wiki](https://github.com/huginn/huginn/wiki/Formatting-Events-using-Liquid) to learn more about liquid templating.
  31. Events generated by this possible Event Formatting Agent will look like:
  32. {
  33. "message": "Today's conditions look like Rain showers with a high temperature of 18 degrees Celsius.",
  34. "subject": "This is some data"
  35. }
  36. In `matchers` setting you can perform regular expression matching against contents of events and expand the match data for use in `instructions` setting. Here is an example:
  37. {
  38. "matchers": [
  39. {
  40. "path": "{{date.pretty}}",
  41. "regexp": "\\A(?<time>\\d\\d:\\d\\d [AP]M [A-Z]+)",
  42. "to": "pretty_date"
  43. }
  44. ]
  45. }
  46. This virtually merges the following hash into the original event hash:
  47. "pretty_date": {
  48. "time": "10:00 PM EST",
  49. "0": "10:00 PM EST on January 11, 2013"
  50. "1": "10:00 PM EST"
  51. }
  52. You could also use the `regex_extract` filter to achieve the same goal.
  53. So you can use it in `instructions` like this:
  54. "instructions": {
  55. "message": "Today's conditions look like {{conditions}} with a high temperature of {{high.celsius}} degrees Celsius according to the forecast at {{pretty_date.time}}.",
  56. "subject": "{{data}}"
  57. }
  58. If you want to retain original contents of events and only add new keys, then set `mode` to `merge`, otherwise set it to `clean`.
  59. To CGI escape output (for example when creating a link), use the Liquid `uri_escape` filter, like so:
  60. {
  61. "message": "A peak was on Twitter in {{group_by}}. Search: https://twitter.com/search?q={{group_by | uri_escape}}"
  62. }
  63. MD
  64. event_description do
  65. "Events will have the following fields%s:\n\n %s" % [
  66. case options['mode'].to_s
  67. when 'merge'
  68. ', merged with the original contents'
  69. when /\{/
  70. ', conditionally merged with the original contents'
  71. end,
  72. Utils.pretty_print(Hash[options['instructions'].keys.map { |key|
  73. [key, "..."]
  74. }])
  75. ]
  76. end
  77. def validate_options
  78. errors.add(:base,
  79. "instructions and mode need to be present.") unless options['instructions'].present? && options['mode'].present?
  80. if options['mode'].present? && !options['mode'].to_s.include?('{{') && !%(clean merge).include?(options['mode'].to_s)
  81. errors.add(:base, "mode must be 'clean' or 'merge'")
  82. end
  83. validate_matchers
  84. end
  85. def default_options
  86. {
  87. 'instructions' => {
  88. 'message' => "You received a text {{text}} from {{fields.from}}",
  89. 'agent' => "{{agent.type}}",
  90. 'some_other_field' => "Looks like the weather is going to be {{fields.weather}}"
  91. },
  92. 'mode' => "clean",
  93. }
  94. end
  95. def working?
  96. !recent_error_logs?
  97. end
  98. def receive(incoming_events)
  99. matchers = compiled_matchers
  100. incoming_events.each do |event|
  101. interpolate_with(event) do
  102. apply_compiled_matchers(matchers, event) do
  103. formatted_event = interpolated['mode'].to_s == "merge" ? event.payload.dup : {}
  104. formatted_event.merge! interpolated['instructions']
  105. create_event payload: formatted_event
  106. end
  107. end
  108. end
  109. end
  110. private
  111. def validate_matchers
  112. matchers = options['matchers'] or return
  113. unless matchers.is_a?(Array)
  114. errors.add(:base, "matchers must be an array if present")
  115. return
  116. end
  117. matchers.each do |matcher|
  118. unless matcher.is_a?(Hash)
  119. errors.add(:base, "each matcher must be a hash")
  120. next
  121. end
  122. regexp, path, to = matcher.values_at(*%w[regexp path to])
  123. if regexp.present?
  124. begin
  125. Regexp.new(regexp)
  126. rescue StandardError
  127. errors.add(:base, "bad regexp found in matchers: #{regexp}")
  128. end
  129. else
  130. errors.add(:base, "regexp is mandatory for a matcher and must be a string")
  131. end
  132. errors.add(:base, "path is mandatory for a matcher and must be a string") if !path.present?
  133. errors.add(:base, "to must be a string if present in a matcher") if to.present? && !to.is_a?(String)
  134. end
  135. end
  136. def compiled_matchers
  137. if matchers = options['matchers']
  138. matchers.map { |matcher|
  139. regexp, path, to = matcher.values_at(*%w[regexp path to])
  140. [Regexp.new(regexp), path, to]
  141. }
  142. end
  143. end
  144. def apply_compiled_matchers(matchers, event, &block)
  145. return yield if matchers.nil?
  146. # event.payload.dup does not work; HashWithIndifferentAccess is
  147. # a source of trouble here.
  148. hash = {}.update(event.payload)
  149. matchers.each do |re, path, to|
  150. m = re.match(interpolate_string(path, hash)) or next
  151. mhash =
  152. if to
  153. case value = hash[to]
  154. when Hash
  155. value
  156. else
  157. hash[to] = {}
  158. end
  159. else
  160. hash
  161. end
  162. m.size.times do |i|
  163. mhash[i.to_s] = m[i]
  164. end
  165. m.names.each do |name|
  166. mhash[name] = m[name]
  167. end
  168. end
  169. interpolate_with(hash, &block)
  170. end
  171. end
  172. end