diff --git a/Gemfile b/Gemfile index f556084a..bece4ea5 100644 --- a/Gemfile +++ b/Gemfile @@ -59,6 +59,7 @@ gem 'faraday', '~> 0.9.0' gem 'faraday_middleware' gem 'typhoeus', '~> 0.6.3' gem 'nokogiri', '~> 1.6.1' +gem 'net-ftp-list', '~> 3.2.8' gem 'wunderground', '~> 1.2.0' gem 'forecast_io', '~> 2.0.0' diff --git a/Gemfile.lock b/Gemfile.lock index 5795b313..569a6ec7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -188,6 +188,7 @@ GEM multipart-post (2.0.0) mysql2 (0.3.16) naught (1.0.0) + net-ftp-list (3.2.8) nokogiri (1.6.3.1) mini_portile (= 0.6.0) oauth (0.4.7) @@ -418,6 +419,7 @@ DEPENDENCIES liquid (~> 2.6.1) mqtt mysql2 (~> 0.3.16) + net-ftp-list (~> 3.2.8) nokogiri (~> 1.6.1) omniauth omniauth-37signals diff --git a/README.md b/README.md index cdeb7b66..15491104 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Follow [@tectonic](https://twitter.com/tectonic) for updates as Huginn evolves, Want to help with Huginn? All contributions are encouraged! You could make UI improvements, add new Agents, write documentation and tutorials, or try tackling [issues tagged with #help-wanted](https://github.com/cantino/huginn/issues?direction=desc&labels=help-wanted&page=1&sort=created&state=open). +Really want an issue fixed/feature implemented? Or maybe you just want to solve some community issues and earn some extra coffee money? Then you should take a look at the [current bounties on Bountysource](https://www.bountysource.com/trackers/282580-huginn). + Have an awesome an idea but not feeling quite up to contributing yet? Head over to our [Official 'suggest an agent' thread ](https://github.com/cantino/huginn/issues/353) and tell us about your cool idea! ## Examples @@ -105,5 +107,5 @@ Huginn is a work in progress and is just getting started. Please get involved! Please fork, add specs, and send pull requests! -[](https://travis-ci.org/cantino/huginn) [](https://coveralls.io/r/cantino/huginn) [](https://bitdeli.com/free "Bitdeli Badge") [](https://gemnasium.com/cantino/huginn) +[](https://travis-ci.org/cantino/huginn) [](https://coveralls.io/r/cantino/huginn) [](https://bitdeli.com/free "Bitdeli Badge") [](https://gemnasium.com/cantino/huginn) [](https://www.bountysource.com/trackers/282580-huginn?utm_source=282580&utm_medium=shield&utm_campaign=TRACKER_BADGE) diff --git a/app/assets/javascripts/diagram.js.coffee b/app/assets/javascripts/diagram.js.coffee new file mode 100644 index 00000000..1782ff72 --- /dev/null +++ b/app/assets/javascripts/diagram.js.coffee @@ -0,0 +1,20 @@ +$ -> + svg = document.querySelector('.agent-diagram svg.diagram') + overlay = document.querySelector('.agent-diagram .overlay') + getTopLeft = (node) -> + bbox = node.getBBox() + point = svg.createSVGPoint() + point.x = bbox.x + bbox.width + point.y = bbox.y + point.matrixTransform(node.getCTM()) + $(svg).find('g.node[data-badge-id]').each -> + tl = getTopLeft(this) + $('#' + this.getAttribute('data-badge-id'), overlay).each -> + badge = $(this) + badge.css + left: tl.x - badge.outerWidth() * (2/3) + top: tl.y - badge.outerHeight() * (1/3) + 'background-color': badge.find('.label').css('background-color') + .show() + return + return diff --git a/app/assets/stylesheets/diagram.css.scss b/app/assets/stylesheets/diagram.css.scss new file mode 100644 index 00000000..c068ca62 --- /dev/null +++ b/app/assets/stylesheets/diagram.css.scss @@ -0,0 +1,30 @@ +.agent-diagram { + position: relative; + z-index: auto; + + svg.diagram { + position: absolute; + z-index: 1; + } + + .overlay-container { + position: absolute; + top: 0; + left: 0; + z-index: auto; + + .overlay { + position: relative; + z-index: auto; + width: 100%; + height: 100%; + + .badge { + position: absolute; + display: none; + color: white !important; + z-index: 2; + } + } + } +} diff --git a/app/controllers/agents_controller.rb b/app/controllers/agents_controller.rb index b72686ab..e276cb1a 100644 --- a/app/controllers/agents_controller.rb +++ b/app/controllers/agents_controller.rb @@ -98,14 +98,6 @@ class AgentsController < ApplicationController @agent = current_user.agents.find(params[:id]) end - def diagram - @agents = if params[:scenario_id].present? - current_user.scenarios.find(params[:scenario_id]).agents.includes(:receivers) - else - current_user.agents.includes(:receivers) - end - end - def create @agent = Agent.build_for_type(params[:agent].delete(:type), current_user, diff --git a/app/controllers/diagrams_controller.rb b/app/controllers/diagrams_controller.rb new file mode 100644 index 00000000..6772ea41 --- /dev/null +++ b/app/controllers/diagrams_controller.rb @@ -0,0 +1,9 @@ +class DiagramsController < ApplicationController + def show + @agents = if params[:scenario_id].present? + current_user.scenarios.find(params[:scenario_id]).agents.includes(:receivers) + else + current_user.agents.includes(:receivers) + end + end +end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 20635378..a2fdcd79 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -2,8 +2,8 @@ class EventsController < ApplicationController before_filter :load_event, :except => :index def index - if params[:agent] - @agent = current_user.agents.find(params[:agent]) + if params[:agent_id] + @agent = current_user.agents.find(params[:agent_id]) @events = @agent.events.page(params[:page]) else @events = current_user.events.preload(:agent).page(params[:page]) diff --git a/app/helpers/dot_helper.rb b/app/helpers/dot_helper.rb index 5a5361d7..0b9ae942 100644 --- a/app/helpers/dot_helper.rb +++ b/app/helpers/dot_helper.rb @@ -6,7 +6,7 @@ module DotHelper dot.close_write dot.read } rescue false) - svg.html_safe + decorate_svg(svg, agents).html_safe else tag('img', src: URI('https://chart.googleapis.com/chart').tap { |uri| uri.query = URI.encode_www_form(cht: 'gv', chl: agents_dot(agents)) @@ -57,6 +57,13 @@ module DotHelper end end + def ids(values) + values.each_with_index { |id, i| + raw ' ' if i > 0 + id id + } + end + def attr_list(attrs = nil) return if attrs.nil? attrs = attrs.select { |key, value| value.present? } @@ -86,16 +93,13 @@ module DotHelper end def statement(ids, attrs = nil) - Array(ids).each_with_index { |id, i| - raw ' ' if i > 0 - id id - } + ids Array(ids) attr_list attrs raw ';' end - def block(title, &block) - raw title + def block(*ids, &block) + ids ids raw '{' block.call raw '}' @@ -112,11 +116,7 @@ module DotHelper draw(agents: agents, agent_id: ->agent { 'a%d' % agent.id }, agent_label: ->agent { - if agent.disabled? - '%s (Disabled)' % agent.name - else - agent.name - end.gsub(/(.{20}\S*)\s+/) { + agent.name.gsub(/(.{20}\S*)\s+/) { # Fold after every 20+ characters $1 + "\n" } @@ -128,6 +128,7 @@ module DotHelper def agent_node(agent) node(agent_id[agent], label: agent_label[agent], + tooltip: (agent.short_type.titleize if rich), URL: (agent_url[agent] if rich), style: ('rounded,dashed' if agent.disabled?), color: (@disabled if agent.disabled?), @@ -141,7 +142,7 @@ module DotHelper color: (@disabled if agent.disabled? || receiver.disabled?)) end - block('digraph foo') { + block('digraph', 'Agent Event Flow') { # statement 'graph', rankdir: 'LR' statement 'node', shape: 'box', @@ -160,4 +161,60 @@ module DotHelper } } end + + def decorate_svg(xml, agents) + svg = Nokogiri::XML(xml).at('svg') + + Nokogiri::HTML::Document.new.tap { |doc| + doc << root = Nokogiri::XML::Node.new('div', doc) { |div| + div['class'] = 'agent-diagram' + } + + svg['class'] = 'diagram' + + root << svg + root << overlay_container = Nokogiri::XML::Node.new('div', doc) { |div| + div['class'] = 'overlay-container' + div['style'] = "width: #{svg['width']}; height: #{svg['height']}" + } + overlay_container << overlay = Nokogiri::XML::Node.new('div', doc) { |div| + div['class'] = 'overlay' + } + + svg.xpath('//xmlns:g[@class="node"]', svg.namespaces).each { |node| + agent_id = (node.xpath('./xmlns:title/text()', svg.namespaces).to_s[/\d+/] or next).to_i + agent = agents.find { |a| a.id == agent_id } + + count = agent.events_count + next unless count && count > 0 + + overlay << Nokogiri::XML::Node.new('a', doc) { |badge| + badge['id'] = id = 'b%d' % agent_id + badge['class'] = 'badge' + badge['href'] = events_path(agent: agent) + badge['target'] = '_blank' + badge['title'] = "#{count} events created" + badge.content = count.to_s + + node['data-badge-id'] = id + + badge << Nokogiri::XML::Node.new('span', doc) { |label| + # a dummy label only to obtain the background color + label['class'] = [ + 'label', + if agent.disabled? + 'label-warning' + elsif agent.working? + 'label-success' + else + 'label-danger' + end + ].join(' ') + label['style'] = 'display: none'; + } + } + } + # See also: app/assets/diagram.js.coffee + }.at('div.agent-diagram').to_s + end end diff --git a/app/models/agents/event_formatting_agent.rb b/app/models/agents/event_formatting_agent.rb index 3d04ab80..dbf21f15 100644 --- a/app/models/agents/event_formatting_agent.rb +++ b/app/models/agents/event_formatting_agent.rb @@ -25,9 +25,14 @@ module Agents "instructions": { "message": "Today's conditions look like {{conditions}} with a high temperature of {{high.celsius}} degrees Celsius.", - "subject": "{{data}}" + "subject": "{{data}}", + "created_at": "{{created_at}}" } + Names here like `conditions`, `high` and `data` refer to the corresponding values in the Event hash. + + 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" }}`. + The upstream agent of each received event is accessible via the key `agent`, which has the following attributes: #{''.tap { |s| s << AgentDrop.instance_methods(false).map { |m| "`#{m}`" }.join(', ') }}. Have a look at the [Wiki](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) to learn more about liquid templating. @@ -68,8 +73,6 @@ module Agents If you want to retain original contents of events and only add new keys, then set `mode` to `merge`, otherwise set it to `clean`. - By default, the output event will have a `created_at` field added as well, reflecting the original Event creation time. You can skip this output by setting `skip_created_at` to `true`. - To CGI escape output (for example when creating a link), use the Liquid `uri_escape` filter, like so: { @@ -82,7 +85,7 @@ module Agents after_save :clear_matchers def validate_options - errors.add(:base, "instructions, mode, and skip_created_at all need to be present.") unless options['instructions'].present? && options['mode'].present? && options['skip_created_at'].present? + errors.add(:base, "instructions and mode need to be present.") unless options['instructions'].present? && options['mode'].present? validate_matchers end @@ -96,7 +99,6 @@ module Agents }, 'matchers' => [], 'mode' => "clean", - 'skip_created_at' => "false" } end @@ -110,7 +112,6 @@ module Agents opts = interpolated(event.to_liquid(payload)) formatted_event = opts['mode'].to_s == "merge" ? event.payload.dup : {} formatted_event.merge! opts['instructions'] - formatted_event['created_at'] = event.created_at unless opts['skip_created_at'].to_s == "true" create_event :payload => formatted_event end end diff --git a/app/models/agents/ftpsite_agent.rb b/app/models/agents/ftpsite_agent.rb index a3ae2755..7cb27e81 100644 --- a/app/models/agents/ftpsite_agent.rb +++ b/app/models/agents/ftpsite_agent.rb @@ -1,4 +1,5 @@ require 'net/ftp' +require 'net/ftp/list' require 'uri' require 'time' @@ -105,34 +106,15 @@ module Agents # commands during iteration. list = ftp.list('-a') - month2year = {} - list.each do |line| - mon, day, smtn, rest = line.split(' ', 9)[5..-1] - - # Remove symlink target part if any - filename = rest[/\A(.+?)(?:\s+->\s|\z)/, 1] - + entry = Net::FTP::List.parse line + filename = entry.basename + mtime = Time.parse(entry.mtime.to_s).utc + patterns.any? { |pattern| File.fnmatch?(pattern, filename) } or next - case smtn - when /:/ - if year = month2year[mon] - mtime = Time.parse("#{mon} #{day} #{year} #{smtn} GMT") - else - log "Getting mtime of #{filename}" - mtime = ftp.mtime(filename) - month2year[mon] = mtime.year - end - else - # Do not bother calling MDTM for old files. Losing the - # time part only makes a timestamp go backwards, meaning - # that it will trigger no new event. - mtime = Time.parse("#{mon} #{day} #{smtn} GMT") - end - after < mtime or next yield filename, mtime @@ -193,7 +175,7 @@ module Agents found_entries[filename] }.each { |filename| create_event :payload => { - 'url' => (base_uri + filename).to_s, + 'url' => "#{base_uri}#{filename}", 'filename' => filename, 'timestamp' => found_entries[filename], } diff --git a/app/models/agents/hipchat_agent.rb b/app/models/agents/hipchat_agent.rb index fcd16c64..475ab275 100644 --- a/app/models/agents/hipchat_agent.rb +++ b/app/models/agents/hipchat_agent.rb @@ -31,7 +31,7 @@ module Agents end def validate_options - errors.add(:base, "you need to specify a hipchat auth_token") unless options['auth_token'].present? + errors.add(:base, "you need to specify a hipchat auth_token or provide a credential named hipchat_auth_token") unless options['auth_token'].present? || credential('hipchat_auth_token').present? errors.add(:base, "you need to specify a room_name or a room_name_path") if options['room_name'].blank? && options['room_name_path'].blank? end @@ -40,10 +40,10 @@ module Agents end def receive(incoming_events) - client = HipChat::Client.new(interpolated[:auth_token]) + client = HipChat::Client.new(interpolated[:auth_token] || credential('hipchat_auth_token')) incoming_events.each do |event| mo = interpolated(event) - client[mo[:room_name]].send(mo[:username], mo[:message], :notify => mo[:notify].to_s == 'true' ? 1 : 0, :color => mo[:color]) + client[mo[:room_name]].send(mo[:username], mo[:message], :notify => boolify(mo[:notify]) ? 1 : 0, :color => mo[:color]) end end end diff --git a/app/models/agents/mqtt_agent.rb b/app/models/agents/mqtt_agent.rb index 85c8f811..35c4005e 100644 --- a/app/models/agents/mqtt_agent.rb +++ b/app/models/agents/mqtt_agent.rb @@ -17,7 +17,7 @@ module Agents Simply choose a topic (think email subject line) to publish/listen to, and configure your service. - It's easy to setup your own [broker](http://jpmens.net/2013/09/01/installing-mosquitto-on-a-raspberry-pi/) or connect to a [cloud service](www.cloudmqtt.com) + It's easy to setup your own [broker](http://jpmens.net/2013/09/01/installing-mosquitto-on-a-raspberry-pi/) or connect to a [cloud service](http://www.cloudmqtt.com) Hints: Many services run mqtts (mqtt over SSL) often with a custom certificate. diff --git a/app/models/agents/post_agent.rb b/app/models/agents/post_agent.rb index d239ec2b..f30c7c76 100644 --- a/app/models/agents/post_agent.rb +++ b/app/models/agents/post_agent.rb @@ -69,7 +69,7 @@ module Agents def receive(incoming_events) incoming_events.each do |event| outgoing = interpolated(event)['payload'].presence || {} - if interpolated['no_merge'].to_s == 'true' + if boolify(interpolated['no_merge']) handle outgoing, event.payload else handle outgoing.merge(event.payload), event.payload diff --git a/app/models/agents/trigger_agent.rb b/app/models/agents/trigger_agent.rb index 4c294513..a0a06090 100644 --- a/app/models/agents/trigger_agent.rb +++ b/app/models/agents/trigger_agent.rb @@ -102,7 +102,7 @@ module Agents end def keep_event? - interpolated['keep_event'] == 'true' + boolify(interpolated['keep_event']) end end end diff --git a/app/models/agents/twilio_agent.rb b/app/models/agents/twilio_agent.rb index 81380fb5..1641097a 100644 --- a/app/models/agents/twilio_agent.rb +++ b/app/models/agents/twilio_agent.rb @@ -44,13 +44,13 @@ module Agents incoming_events.each do |event| message = (event.payload['message'].presence || event.payload['text'].presence || event.payload['sms'].presence).to_s if message.present? - if interpolated(event)['receive_call'].to_s == 'true' + if boolify(interpolated(event)['receive_call']) secret = SecureRandom.hex 3 memory['pending_calls'][secret] = message make_call secret end - if interpolated(event)['receive_text'].to_s == 'true' + if boolify(interpolated(event)['receive_text']) message = message.slice 0..160 send_message message end diff --git a/app/models/event.rb b/app/models/event.rb index c877e70b..97bd0ef9 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -56,6 +56,8 @@ class EventDrop case key when 'agent' @object.agent + when 'created_at' + @object.created_at end end end diff --git a/app/views/agents/_table.html.erb b/app/views/agents/_table.html.erb index ddb2171b..1f6c72f4 100644 --- a/app/views/agents/_table.html.erb +++ b/app/views/agents/_table.html.erb @@ -53,7 +53,7 @@
Events created: - <%= link_to @agent.events.count, events_path(:agent => @agent.to_param) %> + <%= link_to @agent.events.count, agent_events_path(@agent) %>
<% end %> diff --git a/app/views/agents/diagram.html.erb b/app/views/diagrams/show.html.erb similarity index 86% rename from app/views/agents/diagram.html.erb rename to app/views/diagrams/show.html.erb index be2bb3d6..5a1de393 100644 --- a/app/views/agents/diagram.html.erb +++ b/app/views/diagrams/show.html.erb @@ -1,3 +1,7 @@ +<% content_for :head do %> + <%= javascript_include_tag "diagram" %> +<% end %> +