module Agents
class LiquidOutputAgent < Agent
include FormConfigurable
cannot_be_scheduled!
cannot_create_events!
DATE_UNITS = %w[second seconds minute minutes hour hours day days week weeks month months year years]
description do
<<~MD
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.
This Agent will output data at:
`https://#{ENV['DOMAIN']}#{Rails.application.routes.url_helpers.web_requests_path(agent_id: ':id', user_id:, secret: ':secret', format: :any_extension)}`
where `:secret` is the secret specified in your options. You can use any extension you wish.
Options:
* `secret` - A token that the requestor must provide for light-weight authentication.
* `expected_receive_period_in_days` - How often you expect data to be received by this Agent from other Agents.
* `content` - The content to display when someone requests this page.
* `line_break_is_lf` - Use LF as line breaks instead of CRLF.
* `mime_type` - The mime type to use when someone requests this page.
* `response_headers` - An object with any custom response headers. (example: `{"Access-Control-Allow-Origin": "*"}`)
* `mode` - The behavior that determines what data is passed to the Liquid template.
* `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".
# Liquid Templating
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.
# Modes
### Merge events
The data for incoming events will be merged. So if two events come in like this:
```
{ 'a' => 'b', 'c' => 'd'}
{ 'a' => 'bb', 'e' => 'f'}
```
The final result will be:
```
{ 'a' => 'bb', 'c' => 'd', 'e' => 'f'}
```
This merged version will be passed to the Liquid template.
### Last event in
The data from the last event will be passed to the template.
### Last X events
All of the events received by this agent will be passed to the template as the `events` array.
The number of events can be controlled via the `event_limit` option.
If `event_limit` is an integer X, the last X events will be passed to the template.
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.
If no `event_limit` is provided, then all of the events for the agent will be passed to the template.
For performance, the maximum `event_limit` allowed is 1000.
MD
end
def default_options
content = <<~EOF
When you use the "Last event in" or "Merge events" option, you can use variables from the last event received, like this:
Name: {{name}}
Url: {{url}}
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:
EOF
{
"secret" => "a-secret-key",
"expected_receive_period_in_days" => 2,
"mime_type" => 'text/html',
"mode" => 'Last event in',
"event_limit" => '',
"content" => content,
}
end
form_configurable :secret
form_configurable :expected_receive_period_in_days
form_configurable :content, type: :text
form_configurable :line_break_is_lf, type: :boolean
form_configurable :mime_type
form_configurable :mode, type: :array, values: ['Last event in', 'Merge events', 'Last X events']
form_configurable :event_limit
before_save :update_last_modified_at, if: :options_changed?
def working?
last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
end
def validate_options
if options['secret'].present?
case options['secret']
when %r{[/.]}
errors.add(:base, "secret may not contain a slash or dot")
when String
else
errors.add(:base, "secret must be a string")
end
else
errors.add(:base, "Please specify one secret for 'authenticating' incoming feed requests")
end
unless options['expected_receive_period_in_days'].present? && options['expected_receive_period_in_days'].to_i > 0
errors.add(
:base,
"Please provide 'expected_receive_period_in_days' to indicate how many days can pass before this Agent is considered to be not working"
)
end
event_limit =
if value = options['event_limit'].presence
begin
Integer(value)
rescue StandardError
false
end
end
if event_limit == false && date_limit.blank?
errors.add(:base, "Event limit must be an integer that is less than 1001 or an integer plus a valid unit.")
elsif event_limit && event_limit > 1000
errors.add(:base, "For performance reasons, you cannot have an event limit greater than 1000.")
end
end
def receive(incoming_events)
return unless ['merge events', 'last event in'].include?(mode)
memory['last_event'] ||= {}
incoming_events.each do |event|
memory['last_event'] =
case mode
when 'merge events'
memory['last_event'].merge(event.payload)
else
event.payload
end
end
update_last_modified_at
end
def receive_web_request(request)
if valid_authentication?(request.params)
if request.headers['If-None-Match'].presence&.include?(etag)
[nil, 304, {}]
else
[liquified_content, 200, mime_type, response_headers]
end
else
[unauthorized_content(request.format.to_s), 401]
end
end
private
def mode
options['mode'].to_s.downcase
end
def unauthorized_content(format)
if format =~ /json/
{ error: "Not Authorized" }
else
"Not Authorized"
end
end
def valid_authentication?(params)
interpolated['secret'] == params['secret']
end
def mime_type
options['mime_type'].presence || 'text/html'
end
def liquified_content
content = interpolated(data_for_liquid_template)['content']
content.gsub!(/\r(?=\n)/, '') if boolify(options['line_break_is_lf'])
content
end
def data_for_liquid_template
case mode
when 'last x events'
events = received_events
events = events.where('events.created_at > ?', date_limit) if date_limit
events = events.limit count_limit
events = events.to_a.map { |x| x.payload }
{ 'events' => events }
else
memory['last_event'] || {}
end
end
public def etag
memory['etag'] || '"0.000000000"'
end
def last_modified_at
memory['last_modified_at']&.to_time || Time.at(0)
end
def last_modified_at=(time)
memory['last_modified_at'] = time.iso8601(9)
memory['etag'] = time.strftime('"%s.%9N"')
end
def update_last_modified_at
self.last_modified_at = Time.now
end
def max_age
options['expected_receive_period_in_days'].to_i * 86400
end
def response_headers
{
'Last-Modified' => last_modified_at.httpdate,
'ETag' => etag,
'Cache-Control' => "max-age=#{max_age}",
}.update(interpolated['response_headers'].presence || {})
end
def count_limit
[Integer(options['event_limit']), 1000].min
rescue StandardError
1000
end
def date_limit
return nil unless options['event_limit'].to_s.include?(' ')
value, unit = options['event_limit'].split(' ')
value = begin
Integer(value)
rescue StandardError
nil
end
return nil unless value
unit = unit.to_s.downcase
return nil unless DATE_UNITS.include?(unit)
value.send(unit.to_sym).ago
end
end
end