liquid_output_agent.rb 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. module Agents
  2. class LiquidOutputAgent < Agent
  3. include FormConfigurable
  4. cannot_be_scheduled!
  5. cannot_create_events!
  6. DATE_UNITS = %w[second seconds minute minutes hour hours day days week weeks month months year years]
  7. description do
  8. <<~MD
  9. The Liquid Output Agent outputs events through a Liquid template you provide. Use it to create a HTML page, or a json feed, or anything else that can be rendered as a string from your stream of Huginn data.
  10. This Agent will output data at:
  11. `https://#{ENV['DOMAIN']}#{Rails.application.routes.url_helpers.web_requests_path(agent_id: ':id', user_id:, secret: ':secret', format: :any_extension)}`
  12. where `:secret` is the secret specified in your options. You can use any extension you wish.
  13. Options:
  14. * `secret` - A token that the requestor must provide for light-weight authentication.
  15. * `expected_receive_period_in_days` - How often you expect data to be received by this Agent from other Agents.
  16. * `content` - The content to display when someone requests this page.
  17. * `line_break_is_lf` - Use LF as line breaks instead of CRLF.
  18. * `mime_type` - The mime type to use when someone requests this page.
  19. * `response_headers` - An object with any custom response headers. (example: `{"Access-Control-Allow-Origin": "*"}`)
  20. * `mode` - The behavior that determines what data is passed to the Liquid template.
  21. * `event_limit` - A limit applied to the events passed to a template when in "Last X events" mode. Can be a count like "1", or an amount of time like "1 day" or "5 minutes".
  22. # Liquid Templating
  23. The content you provide will be run as a [Liquid](https://github.com/huginn/huginn/wiki/Formatting-Events-using-Liquid) template. The data from the last event received will be used when processing the Liquid template.
  24. # Modes
  25. ### Merge events
  26. The data for incoming events will be merged. So if two events come in like this:
  27. ```
  28. { 'a' => 'b', 'c' => 'd'}
  29. { 'a' => 'bb', 'e' => 'f'}
  30. ```
  31. The final result will be:
  32. ```
  33. { 'a' => 'bb', 'c' => 'd', 'e' => 'f'}
  34. ```
  35. This merged version will be passed to the Liquid template.
  36. ### Last event in
  37. The data from the last event will be passed to the template.
  38. ### Last X events
  39. All of the events received by this agent will be passed to the template as the `events` array.
  40. The number of events can be controlled via the `event_limit` option.
  41. If `event_limit` is an integer X, the last X events will be passed to the template.
  42. If `event_limit` is an integer with a unit of measure like "1 day" or "5 minutes" or "9 years", a date filter will be applied to the events passed to the template.
  43. If no `event_limit` is provided, then all of the events for the agent will be passed to the template.
  44. For performance, the maximum `event_limit` allowed is 1000.
  45. MD
  46. end
  47. def default_options
  48. content = <<~EOF
  49. When you use the "Last event in" or "Merge events" option, you can use variables from the last event received, like this:
  50. Name: {{name}}
  51. Url: {{url}}
  52. If you use the "Last X Events" mode, a set of events will be passed to your Liquid template. You can use them like this:
  53. <table class="table">
  54. {% for event in events %}
  55. <tr>
  56. <td>{{ event.title }}</td>
  57. <td><a href="{{ event.url }}">Click here to see</a></td>
  58. </tr>
  59. {% endfor %}
  60. </table>
  61. EOF
  62. {
  63. "secret" => "a-secret-key",
  64. "expected_receive_period_in_days" => 2,
  65. "mime_type" => 'text/html',
  66. "mode" => 'Last event in',
  67. "event_limit" => '',
  68. "content" => content,
  69. }
  70. end
  71. form_configurable :secret
  72. form_configurable :expected_receive_period_in_days
  73. form_configurable :content, type: :text
  74. form_configurable :line_break_is_lf, type: :boolean
  75. form_configurable :mime_type
  76. form_configurable :mode, type: :array, values: ['Last event in', 'Merge events', 'Last X events']
  77. form_configurable :event_limit
  78. before_save :update_last_modified_at, if: :options_changed?
  79. def working?
  80. last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
  81. end
  82. def validate_options
  83. if options['secret'].present?
  84. case options['secret']
  85. when %r{[/.]}
  86. errors.add(:base, "secret may not contain a slash or dot")
  87. when String
  88. else
  89. errors.add(:base, "secret must be a string")
  90. end
  91. else
  92. errors.add(:base, "Please specify one secret for 'authenticating' incoming feed requests")
  93. end
  94. unless options['expected_receive_period_in_days'].present? && options['expected_receive_period_in_days'].to_i > 0
  95. errors.add(
  96. :base,
  97. "Please provide 'expected_receive_period_in_days' to indicate how many days can pass before this Agent is considered to be not working"
  98. )
  99. end
  100. event_limit =
  101. if value = options['event_limit'].presence
  102. begin
  103. Integer(value)
  104. rescue StandardError
  105. false
  106. end
  107. end
  108. if event_limit == false && date_limit.blank?
  109. errors.add(:base, "Event limit must be an integer that is less than 1001 or an integer plus a valid unit.")
  110. elsif event_limit && event_limit > 1000
  111. errors.add(:base, "For performance reasons, you cannot have an event limit greater than 1000.")
  112. end
  113. end
  114. def receive(incoming_events)
  115. return unless ['merge events', 'last event in'].include?(mode)
  116. memory['last_event'] ||= {}
  117. incoming_events.each do |event|
  118. memory['last_event'] =
  119. case mode
  120. when 'merge events'
  121. memory['last_event'].merge(event.payload)
  122. else
  123. event.payload
  124. end
  125. end
  126. update_last_modified_at
  127. end
  128. def receive_web_request(request)
  129. if valid_authentication?(request.params)
  130. if request.headers['If-None-Match'].presence&.include?(etag)
  131. [nil, 304, {}]
  132. else
  133. [liquified_content, 200, mime_type, response_headers]
  134. end
  135. else
  136. [unauthorized_content(request.format.to_s), 401]
  137. end
  138. end
  139. private
  140. def mode
  141. options['mode'].to_s.downcase
  142. end
  143. def unauthorized_content(format)
  144. if format =~ /json/
  145. { error: "Not Authorized" }
  146. else
  147. "Not Authorized"
  148. end
  149. end
  150. def valid_authentication?(params)
  151. interpolated['secret'] == params['secret']
  152. end
  153. def mime_type
  154. options['mime_type'].presence || 'text/html'
  155. end
  156. def liquified_content
  157. content = interpolated(data_for_liquid_template)['content']
  158. content.gsub!(/\r(?=\n)/, '') if boolify(options['line_break_is_lf'])
  159. content
  160. end
  161. def data_for_liquid_template
  162. case mode
  163. when 'last x events'
  164. events = received_events
  165. events = events.where('events.created_at > ?', date_limit) if date_limit
  166. events = events.limit count_limit
  167. events = events.to_a.map { |x| x.payload }
  168. { 'events' => events }
  169. else
  170. memory['last_event'] || {}
  171. end
  172. end
  173. public def etag
  174. memory['etag'] || '"0.000000000"'
  175. end
  176. def last_modified_at
  177. memory['last_modified_at']&.to_time || Time.at(0)
  178. end
  179. def last_modified_at=(time)
  180. memory['last_modified_at'] = time.iso8601(9)
  181. memory['etag'] = time.strftime('"%s.%9N"')
  182. end
  183. def update_last_modified_at
  184. self.last_modified_at = Time.now
  185. end
  186. def max_age
  187. options['expected_receive_period_in_days'].to_i * 86400
  188. end
  189. def response_headers
  190. {
  191. 'Last-Modified' => last_modified_at.httpdate,
  192. 'ETag' => etag,
  193. 'Cache-Control' => "max-age=#{max_age}",
  194. }.update(interpolated['response_headers'].presence || {})
  195. end
  196. def count_limit
  197. [Integer(options['event_limit']), 1000].min
  198. rescue StandardError
  199. 1000
  200. end
  201. def date_limit
  202. return nil unless options['event_limit'].to_s.include?(' ')
  203. value, unit = options['event_limit'].split(' ')
  204. value = begin
  205. Integer(value)
  206. rescue StandardError
  207. nil
  208. end
  209. return nil unless value
  210. unit = unit.to_s.downcase
  211. return nil unless DATE_UNITS.include?(unit)
  212. value.send(unit.to_sym).ago
  213. end
  214. end
  215. end