Explorar el Código

Merge remote-tracking branch 'huginn/master' into omniauth

Conflicts:
	app/models/agents/basecamp_agent.rb
	app/models/user.rb
	db/schema.rb
	spec/fixtures/agents.yml
Dominik Sander hace 10 años
padre
commit
f07db9c97c
Se han modificado 100 ficheros con 1879 adiciones y 400 borrados
  1. 11 5
      app/assets/javascripts/application.js.coffee.erb
  2. 18 2
      app/assets/stylesheets/application.css.scss.erb
  3. 15 0
      app/assets/stylesheets/scenarios.css.scss
  4. 1 1
      app/concerns/assignable_types.rb
  5. 0 3
      app/concerns/email_concern.rb
  6. 13 0
      app/concerns/has_guid.rb
  7. 0 0
      app/concerns/inheritance_tracking.rb
  8. 0 0
      app/concerns/json_serialized_field.rb
  9. 17 13
      app/concerns/liquid_interpolatable.rb
  10. 0 0
      app/concerns/markdown_class_attributes.rb
  11. 53 21
      app/controllers/agents_controller.rb
  12. 20 0
      app/controllers/scenario_imports_controller.rb
  13. 100 0
      app/controllers/scenarios_controller.rb
  14. 6 0
      app/helpers/agent_helper.rb
  15. 1 0
      app/helpers/dot_helper.rb
  16. 10 1
      app/models/agent.rb
  17. 4 4
      app/models/agents/adioso_agent.rb
  18. 1 1
      app/models/agents/basecamp_agent.rb
  19. 7 8
      app/models/agents/data_output_agent.rb
  20. 1 1
      app/models/agents/email_agent.rb
  21. 1 1
      app/models/agents/email_digest_agent.rb
  22. 6 6
      app/models/agents/event_formatting_agent.rb
  23. 4 4
      app/models/agents/ftpsite_agent.rb
  24. 6 6
      app/models/agents/growl_agent.rb
  25. 2 4
      app/models/agents/hipchat_agent.rb
  26. 6 8
      app/models/agents/human_task_agent.rb
  27. 7 7
      app/models/agents/imap_folder_agent.rb
  28. 7 9
      app/models/agents/jabber_agent.rb
  29. 7 7
      app/models/agents/java_script_agent.rb
  30. 8 8
      app/models/agents/jira_agent.rb
  31. 11 11
      app/models/agents/mqtt_agent.rb
  32. 11 13
      app/models/agents/peak_detector_agent.rb
  33. 6 6
      app/models/agents/post_agent.rb
  34. 4 4
      app/models/agents/public_transport_agent.rb
  35. 3 4
      app/models/agents/pushbullet_agent.rb
  36. 30 30
      app/models/agents/pushover_agent.rb
  37. 2 2
      app/models/agents/sentiment_agent.rb
  38. 8 7
      app/models/agents/shell_command_agent.rb
  39. 4 5
      app/models/agents/slack_agent.rb
  40. 1 1
      app/models/agents/stubhub_agent.rb
  41. 6 8
      app/models/agents/translation_agent.rb
  42. 9 8
      app/models/agents/trigger_agent.rb
  43. 9 9
      app/models/agents/twilio_agent.rb
  44. 2 3
      app/models/agents/twitter_publish_agent.rb
  45. 5 5
      app/models/agents/twitter_stream_agent.rb
  46. 5 5
      app/models/agents/twitter_user_agent.rb
  47. 7 7
      app/models/agents/weather_agent.rb
  48. 4 4
      app/models/agents/webhook_agent.rb
  49. 22 22
      app/models/agents/website_agent.rb
  50. 3 3
      app/models/agents/weibo_publish_agent.rb
  51. 3 3
      app/models/agents/weibo_user_agent.rb
  52. 19 0
      app/models/scenario.rb
  53. 256 0
      app/models/scenario_import.rb
  54. 4 0
      app/models/scenario_membership.rb
  55. 2 2
      app/models/user.rb
  56. 12 2
      app/views/agents/_action_menu.html.erb
  57. 14 2
      app/views/agents/_form.html.erb
  58. 75 0
      app/views/agents/_table.html.erb
  59. 1 1
      app/views/agents/agent_views/manual_event_agent/_show.html.erb
  60. 1 1
      app/views/agents/diagram.html.erb
  61. 1 70
      app/views/agents/index.html.erb
  62. 1 1
      app/views/agents/show.html.erb
  63. 2 2
      app/views/events/index.html.erb
  64. 1 0
      app/views/layouts/_navigation.html.erb
  65. 14 10
      app/views/layouts/application.html.erb
  66. 31 0
      app/views/scenario_imports/_step_one.html.erb
  67. 154 0
      app/views/scenario_imports/_step_two.html.erb
  68. 32 0
      app/views/scenario_imports/new.html.erb
  69. 57 0
      app/views/scenarios/_form.html.erb
  70. 21 0
      app/views/scenarios/edit.html.erb
  71. 50 0
      app/views/scenarios/index.html.erb
  72. 21 0
      app/views/scenarios/new.html.erb
  73. 33 0
      app/views/scenarios/share.html.erb
  74. 28 0
      app/views/scenarios/show.html.erb
  75. 12 0
      config/routes.rb
  76. 12 0
      db/migrate/20140509170420_create_scenarios.rb
  77. 10 0
      db/migrate/20140509170443_create_scenario_memberships.rb
  78. 8 0
      db/migrate/20140531232016_add_fields_to_scenarios.rb
  79. 7 0
      db/migrate/20140602014917_add_indices_to_scenarios.rb
  80. 15 0
      db/migrate/20140605032822_add_guid_to_agents.rb
  81. 27 1
      db/schema.rb
  82. 54 0
      lib/agents_exporter.rb
  83. 0 0
      spec/concerns/inheritance_tracking_spec.rb
  84. 103 0
      spec/controllers/agents_controller_spec.rb
  85. 26 0
      spec/controllers/scenario_imports_controller_spec.rb
  86. 152 0
      spec/controllers/scenarios_controller_spec.rb
  87. 10 1
      spec/fixtures/agents.yml
  88. 15 0
      spec/fixtures/scenario_memberships.yml
  89. 13 0
      spec/fixtures/scenarios.yml
  90. 3 1
      spec/fixtures/users.yml
  91. 61 0
      spec/lib/agents_exporter_spec.rb
  92. 28 1
      spec/models/agent_spec.rb
  93. 0 3
      spec/models/agents/data_output_agent_spec.rb
  94. 0 3
      spec/models/agents/hipchat_agent_spec.rb
  95. 0 3
      spec/models/agents/human_task_agent_spec.rb
  96. 0 3
      spec/models/agents/jabber_agent_spec.rb
  97. 0 3
      spec/models/agents/peak_detector_agent_spec.rb
  98. 0 3
      spec/models/agents/pushbullet_agent_spec.rb
  99. 4 3
      spec/models/agents/shell_command_agent_spec.rb
  100. 2 4
      spec/models/agents/slack_agent_spec.rb

+ 11 - 5
app/assets/javascripts/application.js.coffee.erb

@@ -9,14 +9,17 @@
 #= require ./worker-checker
 #= require_self
 
-window.setupJsonEditor = ($editor = $(".live-json-editor")) ->
+window.setupJsonEditor = ($editors = $(".live-json-editor")) ->
   JSONEditor.prototype.ADD_IMG = '<%= image_path 'json-editor/add.png' %>'
   JSONEditor.prototype.DELETE_IMG = '<%= image_path 'json-editor/delete.png' %>'
-  if $editor.length
+  editors = []
+  $editors.each ->
+    $editor = $(this)
     jsonEditor = new JSONEditor($editor, $editor.data('width') || 400, $editor.data('height') || 500)
     jsonEditor.doTruncation true
     jsonEditor.showFunctionButtons()
-    return jsonEditor
+    editors.push jsonEditor
+  return editors
 
 hideSchedule = ->
   $(".schedule-region select").hide()
@@ -55,12 +58,15 @@ showEventDescriptions = ->
 
 $(document).ready ->
   # JSON Editor
-  window.jsonEditor = setupJsonEditor()
+  window.jsonEditor = setupJsonEditor()[0]
 
   # Flash
   if $(".flash").length
     setTimeout((-> $(".flash").slideUp(-> $(".flash").remove())), 5000)
 
+  # Help popovers
+  $('.hover-help').popover(trigger: 'hover')
+
   # Agent Navigation
   $agentNavigate = $('#agent-navigate')
 
@@ -99,7 +105,7 @@ $(document).ready ->
         e.preventDefault()
         $agentNavigate.focus()
 
-# Agent Show
+  # Agent Show
   fetchLogs = (e) ->
     agentId = $(e.target).closest("[data-agent-id]").data("agent-id")
     e.preventDefault()

+ 18 - 2
app/assets/stylesheets/application.css.scss.erb

@@ -140,13 +140,29 @@ span.not-applicable:after {
   opacity: 0.5;
 }
 
-// Fix JSON Editor
+// JSON Editor
+
+.live-json-editor {
+  font-family: "Courier New", Courier, monospace;
+}
 
 .json-editor blockquote {
   font-size: 14px;
 }
 
-// Bootstrappy colour styles
+// Position tweeks
+
+.hover-help {
+  top: 2px;
+}
+
+h2 .scenario, a span.label.scenario {
+  position: relative;
+  top: -2px;
+}
+
+// Bootstrappy color styles
+
 .color-danger {
   color: #d9534f;
 }

+ 15 - 0
app/assets/stylesheets/scenarios.css.scss

@@ -0,0 +1,15 @@
+.scenario-import {
+  .agent-import-list {
+    .agent-import {
+      margin-bottom: 20px;
+
+      .instructions {
+        margin-bottom: 10px;
+      }
+
+      .current {
+        font-weight: bold;
+      }
+    }
+  }
+}

+ 1 - 1
lib/assignable_types.rb → app/concerns/assignable_types.rb

@@ -29,7 +29,7 @@ module AssignableTypes
       const_get(:TYPES).include?(type)
     end
 
-    def build_for_type(type, user, attributes)
+    def build_for_type(type, user, attributes = {})
       attributes.delete(:type)
 
       if valid_type?(type)

+ 0 - 3
app/concerns/email_concern.rb

@@ -31,7 +31,4 @@ module EmailConcern
   def present_hash(hash, skip_key = nil)
     hash.to_a.sort_by {|a| a.first.to_s }.map { |k, v| "#{k}: #{v}" unless k.to_s == skip_key.to_s }.compact
   end
-
-  module ClassMethods
-  end
 end

+ 13 - 0
app/concerns/has_guid.rb

@@ -0,0 +1,13 @@
+module HasGuid
+  extend ActiveSupport::Concern
+
+  included do
+    before_save :make_guid
+  end
+
+  protected
+
+  def make_guid
+    self.guid = SecureRandom.hex unless guid.present?
+  end
+end

+ 0 - 0
lib/inheritance_tracking.rb → app/concerns/inheritance_tracking.rb


+ 0 - 0
lib/json_serialized_field.rb → app/concerns/json_serialized_field.rb


+ 17 - 13
app/concerns/liquid_interpolatable.rb

@@ -1,22 +1,26 @@
 module LiquidInterpolatable
   extend ActiveSupport::Concern
 
-  def interpolate_options(options, payload)
-    case options.class.to_s
-    when 'String'
-      interpolate_string(options, payload)
-    when 'ActiveSupport::HashWithIndifferentAccess', 'Hash'
-      duped_options = options.dup
-      duped_options.each do |key, value|
-        duped_options[key] = interpolate_options(value, payload)
-      end
-    when 'Array'
-      options.collect do |value|
-        interpolate_options(value, payload)
-      end
+  def interpolate_options(options, payload = {})
+    case options
+      when String
+        interpolate_string(options, payload)
+      when ActiveSupport::HashWithIndifferentAccess, Hash
+        options.inject(ActiveSupport::HashWithIndifferentAccess.new) { |memo, (key, value)| memo[key] = interpolate_options(value, payload); memo }
+      when Array
+        options.map { |value| interpolate_options(value, payload) }
+      else
+        options
     end
   end
 
+  def interpolated(payload = {})
+    key = [options, payload]
+    @interpolated_cache ||= {}
+    @interpolated_cache[key] ||= interpolate_options(options, payload)
+    @interpolated_cache[key]
+  end
+
   def interpolate_string(string, payload)
     Liquid::Template.parse(string).render!(payload, registers: {agent: self})
   end

+ 0 - 0
lib/markdown_class_attributes.rb → app/concerns/markdown_class_attributes.rb


+ 53 - 21
app/controllers/agents_controller.rb

@@ -21,12 +21,12 @@ class AgentsController < ApplicationController
   end
 
   def run
-    agent = current_user.agents.find(params[:id])
-    Agent.async_check(agent.id)
-    if params[:return] == "show"
-      redirect_to agent_path(agent), notice: "Agent run queued"
-    else
-      redirect_to agents_path, notice: "Agent run queued"
+    @agent = current_user.agents.find(params[:id])
+    Agent.async_check(@agent.id)
+
+    respond_to do |format|
+      format.html { redirect_back "Agent run queued for '#{@agent.name}'" }
+      format.json { head :ok }
     end
   end
 
@@ -54,12 +54,20 @@ class AgentsController < ApplicationController
   def remove_events
     @agent = current_user.agents.find(params[:id])
     @agent.events.delete_all
-    redirect_to agents_path, notice: "All events removed"
+
+    respond_to do |format|
+      format.html { redirect_back "All emitted events removed for '#{@agent.name}'" }
+      format.json { head :ok }
+    end
   end
 
   def propagate
-    details = Agent.receive!
-    redirect_to agents_path, notice: "Queued propagation calls for #{details[:event_count]} event(s) on #{details[:agent_count]} agent(s)"
+    details = Agent.receive! # Eventually this should probably be scoped to the current_user.
+
+    respond_to do |format|
+      format.html { redirect_back "Queued propagation calls for #{details[:event_count]} event(s) on #{details[:agent_count]} agent(s)" }
+      format.json { head :ok }
+    end
   end
 
   def show
@@ -91,7 +99,11 @@ class AgentsController < ApplicationController
   end
 
   def diagram
-    @agents = current_user.agents.includes(:receivers)
+    @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
@@ -100,8 +112,8 @@ class AgentsController < ApplicationController
                                   params[:agent])
     respond_to do |format|
       if @agent.save
-        format.html { redirect_to agents_path, notice: 'Your Agent was successfully created.' }
-        format.json { render json: @agent, status: :created, location: @agent }
+        format.html { redirect_back "'#{@agent.name}' was successfully created." }
+        format.json { render json: @agent, status: :ok, location: agent_path(@agent) }
       else
         format.html { render action: "new" }
         format.json { render json: @agent.errors, status: :unprocessable_entity }
@@ -114,14 +126,8 @@ class AgentsController < ApplicationController
 
     respond_to do |format|
       if @agent.update_attributes(params[:agent])
-        format.html {
-          if params[:return] == "show"
-            redirect_to agent_path(@agent), notice: 'Your Agent was successfully updated.'
-          else
-            redirect_to agents_path, notice: 'Your Agent was successfully updated.'
-          end
-        }
-        format.json { head :no_content }
+        format.html { redirect_back "'#{@agent.name}' was successfully updated." }
+        format.json { render json: @agent, status: :ok, location: agent_path(@agent) }
       else
         format.html { render action: "edit" }
         format.json { render json: @agent.errors, status: :unprocessable_entity }
@@ -129,13 +135,39 @@ class AgentsController < ApplicationController
     end
   end
 
+  def leave_scenario
+    @agent = current_user.agents.find(params[:id])
+    @scenario = current_user.scenarios.find(params[:scenario_id])
+    @agent.scenarios.destroy(@scenario)
+
+    respond_to do |format|
+      format.html { redirect_back "'#{@agent.name}' removed from '#{@scenario.name}'" }
+      format.json { head :no_content }
+    end
+  end
+
   def destroy
     @agent = current_user.agents.find(params[:id])
     @agent.destroy
 
     respond_to do |format|
-      format.html { redirect_to agents_path }
+      format.html { redirect_back "'#{@agent.name}' deleted" }
       format.json { head :no_content }
     end
   end
+
+  protected
+
+  # Sanitize params[:return] to prevent open redirect attacks, a common security issue.
+  def redirect_back(message)
+    if params[:return] == "show" && @agent
+      path = agent_path(@agent)
+    elsif params[:return] =~ /\A#{Regexp::escape scenarios_path}\/\d+\Z/
+      path = params[:return]
+    else
+      path = agents_path
+    end
+
+    redirect_to path, notice: message
+  end
 end

+ 20 - 0
app/controllers/scenario_imports_controller.rb

@@ -0,0 +1,20 @@
+class ScenarioImportsController < ApplicationController
+  def new
+    @scenario_import = ScenarioImport.new(:url => params[:url])
+  end
+
+  def create
+    @scenario_import = ScenarioImport.new(params[:scenario_import])
+    @scenario_import.set_user(current_user)
+
+    if @scenario_import.will_request_local?(scenarios_url)
+      render :text => 'Sorry, you cannot import a Scenario by URL from your own Huginn server.' and return
+    end
+
+    if @scenario_import.valid? && @scenario_import.should_import? && @scenario_import.import
+      redirect_to @scenario_import.scenario, notice: "Import successful!"
+    else
+      render action: "new"
+    end
+  end
+end

+ 100 - 0
app/controllers/scenarios_controller.rb

@@ -0,0 +1,100 @@
+class ScenariosController < ApplicationController
+  skip_before_filter :authenticate_user!, :only => :export
+
+  def index
+    @scenarios = current_user.scenarios.page(params[:page])
+
+    respond_to do |format|
+      format.html
+      format.json { render json: @scenarios }
+    end
+  end
+
+  def new
+    @scenario = current_user.scenarios.build
+
+    respond_to do |format|
+      format.html
+      format.json { render json: @scenario }
+    end
+  end
+
+  def show
+    @scenario = current_user.scenarios.find(params[:id])
+    @agents = @scenario.agents.preload(:scenarios).page(params[:page])
+
+    respond_to do |format|
+      format.html
+      format.json { render json: @scenario }
+    end
+  end
+
+  def share
+    @scenario = current_user.scenarios.find(params[:id])
+
+    respond_to do |format|
+      format.html
+      format.json { render json: @scenario }
+    end
+  end
+
+  def export
+    @scenario = Scenario.find(params[:id])
+    raise ActiveRecord::RecordNotFound unless @scenario.public? || (current_user && current_user.id == @scenario.user_id)
+
+    @exporter = AgentsExporter.new(:name => @scenario.name,
+                                   :description => @scenario.description,
+                                   :guid => @scenario.guid,
+                                   :source_url => @scenario.public? && export_scenario_url(@scenario),
+                                   :agents => @scenario.agents)
+    response.headers['Content-Disposition'] = 'attachment; filename="' + @exporter.filename + '"'
+    render :json => JSON.pretty_generate(@exporter.as_json)
+  end
+
+  def edit
+    @scenario = current_user.scenarios.find(params[:id])
+
+    respond_to do |format|
+      format.html
+      format.json { render json: @scenario }
+    end
+  end
+
+  def create
+    @scenario = current_user.scenarios.build(params[:scenario])
+
+    respond_to do |format|
+      if @scenario.save
+        format.html { redirect_to @scenario, notice: 'This Scenario was successfully created.' }
+        format.json { render json: @scenario, status: :created, location: @scenario }
+      else
+        format.html { render action: "new" }
+        format.json { render json: @scenario.errors, status: :unprocessable_entity }
+      end
+    end
+  end
+
+  def update
+    @scenario = current_user.scenarios.find(params[:id])
+
+    respond_to do |format|
+      if @scenario.update_attributes(params[:scenario])
+        format.html { redirect_to @scenario, notice: 'This Scenario was successfully updated.' }
+        format.json { head :no_content }
+      else
+        format.html { render action: "edit" }
+        format.json { render json: @scenario.errors, status: :unprocessable_entity }
+      end
+    end
+  end
+
+  def destroy
+    @scenario = current_user.scenarios.find(params[:id])
+    @scenario.destroy
+
+    respond_to do |format|
+      format.html { redirect_to scenarios_path }
+      format.json { head :no_content }
+    end
+  end
+end

+ 6 - 0
app/helpers/agent_helper.rb

@@ -6,6 +6,12 @@ module AgentHelper
     end
   end
 
+  def scenario_links(agent)
+    agent.scenarios.map { |scenario|
+      link_to(scenario.name, scenario, class: "label label-info")
+    }.join(" ").html_safe
+  end
+
   def agent_show_class(agent)
     agent.short_type.underscore.dasherize
   end

+ 1 - 0
app/helpers/dot_helper.rb

@@ -35,6 +35,7 @@ module DotHelper
           dot << '%s;' % disabled_label(agent)
         end
         agent.receivers.each do |receiver|
+          next unless agents.include?(receiver)
           dot << "%s->%s;" % [disabled_label(agent), disabled_label(receiver)]
         end
       end

+ 10 - 1
app/models/agent.rb

@@ -12,6 +12,8 @@ class Agent < ActiveRecord::Base
   include JSONSerializedField
   include RDBMSFunctions
   include WorkingHelpers
+  include LiquidInterpolatable
+  include HasGuid
 
   markdown_class_attributes :description, :event_description
 
@@ -22,13 +24,14 @@ class Agent < ActiveRecord::Base
 
   EVENT_RETENTION_SCHEDULES = [["Forever", 0], ["1 day", 1], *([2, 3, 4, 5, 7, 14, 21, 30, 45, 90, 180, 365].map {|n| ["#{n} days", n] })]
 
-  attr_accessible :options, :memory, :name, :type, :schedule, :disabled, :source_ids, :keep_events_for, :propagate_immediately
+  attr_accessible :options, :memory, :name, :type, :schedule, :disabled, :source_ids, :scenario_ids, :keep_events_for, :propagate_immediately
 
   json_serialize :options, :memory
 
   validates_presence_of :name, :user
   validates_inclusion_of :keep_events_for, :in => EVENT_RETENTION_SCHEDULES.map(&:last)
   validate :sources_are_owned
+  validate :scenarios_are_owned
   validate :validate_schedule
   validate :validate_options
 
@@ -49,6 +52,8 @@ class Agent < ActiveRecord::Base
   has_many :links_as_receiver, :dependent => :delete_all, :foreign_key => "receiver_id", :class_name => "Link", :inverse_of => :receiver
   has_many :sources, :through => :links_as_receiver, :class_name => "Agent", :inverse_of => :receivers
   has_many :receivers, :through => :links_as_source, :class_name => "Agent", :inverse_of => :sources
+  has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :agent
+  has_many :scenarios, :through => :scenario_memberships, :inverse_of => :agents
 
   scope :of_type, lambda { |type|
     type = case type
@@ -207,6 +212,10 @@ class Agent < ActiveRecord::Base
     errors.add(:sources, "must be owned by you") unless sources.all? {|s| s.user == user }
   end
   
+  def scenarios_are_owned
+    errors.add(:scenarios, "must be owned by you") unless scenarios.all? {|s| s.user == user }
+  end
+
   def validate_schedule
     unless cannot_be_scheduled?
       errors.add(:schedule, "is not a valid schedule") unless SCHEDULES.include?(schedule.to_s)

+ 4 - 4
app/models/agents/adioso_agent.rb

@@ -54,9 +54,9 @@ module Agents
     end
 
     def check
-      auth_options = {:basic_auth => {:username =>options[:username], :password=>options['password']}}
-      parse_response = HTTParty.get "http://api.adioso.com/v2/search/parse?q=#{URI.encode(options['from'])}+to+#{URI.encode(options['to'])}", auth_options
-      fare_request = parse_response["search_url"].gsub /(end=)(\d*)([^\d]*)(\d*)/, "\\1#{date_to_unix_epoch(options['end_date'])}\\3#{date_to_unix_epoch(options['start_date'])}"
+      auth_options = {:basic_auth => {:username =>interpolated[:username], :password=>interpolated['password']}}
+      parse_response = HTTParty.get "http://api.adioso.com/v2/search/parse?q=#{URI.encode(interpolated['from'])}+to+#{URI.encode(interpolated['to'])}", auth_options
+      fare_request = parse_response["search_url"].gsub /(end=)(\d*)([^\d]*)(\d*)/, "\\1#{date_to_unix_epoch(interpolated['end_date'])}\\3#{date_to_unix_epoch(interpolated['start_date'])}"
       fare = HTTParty.get fare_request, auth_options
 
 			if fare["warnings"]
@@ -64,7 +64,7 @@ module Agents
 			else
 				event = fare["results"].min {|a,b| a["cost"] <=> b["cost"]}
 				event["date"]  = Time.at(event["date"]).to_date.httpdate[0..15]
-				event["route"] = "#{options['from']} to #{options['to']}"
+				event["route"] = "#{interpolated['from']} to #{interpolated['to']}"
 				create_event :payload => event
 			end
     end

+ 1 - 1
app/models/agents/basecamp_agent.rb

@@ -72,7 +72,7 @@ module Agents
 
   private
     def request_url
-      "https://basecamp.com/#{URI.encode(self.service.options[:user_id].to_s)}/api/v1/projects/#{URI.encode(options[:project_id].to_s)}/events.json"
+      "https://basecamp.com/#{URI.encode(self.service.options[:user_id].to_s)}/api/v1/projects/#{URI.encode(interpolated[:project_id].to_s)}/events.json"
     end
 
     def request_options

+ 7 - 8
app/models/agents/data_output_agent.rb

@@ -1,7 +1,5 @@
 module Agents
   class DataOutputAgent < Agent
-    include LiquidInterpolatable
-
     cannot_be_scheduled!
 
     description  do
@@ -52,6 +50,7 @@ module Agents
       unless options['secrets'].is_a?(Array) && options['secrets'].length > 0
         errors.add(:base, "Please specify one or more secrets 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
@@ -62,27 +61,27 @@ module Agents
     end
 
     def events_to_show
-      (options['events_to_show'].presence || 40).to_i
+      (interpolated['events_to_show'].presence || 40).to_i
     end
 
     def feed_ttl
-      (options['ttl'].presence || 60).to_i
+      (interpolated['ttl'].presence || 60).to_i
     end
 
     def feed_title
-      options['template']['title'].presence || "#{name} Event Feed"
+      interpolated['template']['title'].presence || "#{name} Event Feed"
     end
 
     def feed_link
-      options['template']['link'].presence || "https://#{ENV['DOMAIN']}"
+      interpolated['template']['link'].presence || "https://#{ENV['DOMAIN']}"
     end
 
     def feed_description
-      options['template']['description'].presence || "A feed of Events received by the '#{name}' Huginn Agent"
+      interpolated['template']['description'].presence || "A feed of Events received by the '#{name}' Huginn Agent"
     end
 
     def receive_web_request(params, method, format)
-      if options['secrets'].include?(params['secret'])
+      if interpolated['secrets'].include?(params['secret'])
         items = received_events.order('id desc').limit(events_to_show).map do |event|
           interpolated = interpolate_options(options['template']['item'], event.payload)
           interpolated['guid'] = event.id

+ 1 - 1
app/models/agents/email_agent.rb

@@ -25,7 +25,7 @@ module Agents
     def receive(incoming_events)
       incoming_events.each do |event|
         log "Sending digest mail to #{user.email} with event #{event.id}"
-        SystemMailer.delay.send_message(:to => user.email, :subject => options['subject'], :headline => options['headline'], :groups => [present(event.payload)])
+        SystemMailer.delay.send_message(:to => user.email, :subject => interpolated(event.payload)['subject'], :headline => interpolated(event.payload)['headline'], :groups => [present(event.payload)])
       end
     end
   end

+ 1 - 1
app/models/agents/email_digest_agent.rb

@@ -37,7 +37,7 @@ module Agents
         ids = self.memory['events'].join(",")
         groups = self.memory['queue'].map { |payload| present(payload) }
         log "Sending digest mail to #{user.email} with events [#{ids}]"
-        SystemMailer.delay.send_message(:to => user.email, :subject => options['subject'], :headline => options['headline'], :groups => groups)
+        SystemMailer.delay.send_message(:to => user.email, :subject => interpolated['subject'], :headline => interpolated['headline'], :groups => groups)
         self.memory['queue'] = []
         self.memory['events'] = []
       end

+ 6 - 6
app/models/agents/event_formatting_agent.rb

@@ -1,6 +1,5 @@
 module Agents
   class EventFormattingAgent < Agent
-    include LiquidInterpolatable
     cannot_be_scheduled!
 
     description <<-MD
@@ -81,7 +80,7 @@ module Agents
     after_save :clear_matchers
 
     def validate_options
-      errors.add(:base, "instructions, mode, skip_agent, and skip_created_at all need to be present.") unless options['instructions'].present? and options['mode'].present? and options['skip_agent'].present? and options['skip_created_at'].present?
+      errors.add(:base, "instructions, mode, skip_agent, and skip_created_at all need to be present.") unless options['instructions'].present? && options['mode'].present? && options['skip_agent'].present? && options['skip_created_at'].present?
 
       validate_matchers
     end
@@ -105,11 +104,12 @@ module Agents
 
     def receive(incoming_events)
       incoming_events.each do |event|
-        formatted_event = options['mode'].to_s == "merge" ? event.payload.dup : {}
         payload = perform_matching(event.payload)
-        formatted_event.merge! interpolate_options(options['instructions'], payload)
-        formatted_event['agent'] = Agent.find(event.agent_id).type.slice!(8..-1) unless options['skip_agent'].to_s == "true"
-        formatted_event['created_at'] = event.created_at unless options['skip_created_at'].to_s == "true"
+        opts = interpolated(payload)
+        formatted_event = opts['mode'].to_s == "merge" ? event.payload.dup : {}
+        formatted_event.merge! opts['instructions']
+        formatted_event['agent'] = Agent.find(event.agent_id).type.slice!(8..-1) unless opts['skip_agent'].to_s == "true"
+        formatted_event['created_at'] = event.created_at unless opts['skip_created_at'].to_s == "true"
         create_event :payload => formatted_event
       end
     end

+ 4 - 4
app/models/agents/ftpsite_agent.rb

@@ -29,7 +29,7 @@ module Agents
     MD
 
     def working?
-      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
     end
 
     def default_options
@@ -90,10 +90,10 @@ module Agents
     end
 
     def each_entry
-      patterns = options['patterns']
+      patterns = interpolated['patterns']
 
       after =
-        if str = options['after']
+        if str = interpolated['after']
           Time.parse(str)
         else
           Time.at(0)
@@ -174,7 +174,7 @@ module Agents
     end
 
     def base_uri
-      @base_uri ||= URI(options['url'])
+      @base_uri ||= URI(interpolated['url'])
     end
 
     def saving_entries

+ 6 - 6
app/models/agents/growl_agent.rb

@@ -26,7 +26,7 @@ module Agents
     end
     
     def working?
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
     end
 
     def validate_options
@@ -36,13 +36,13 @@ module Agents
     end
     
     def register_growl
-      @growler = Growl.new options['growl_server'], options['growl_app_name'], "GNTP"
-      @growler.password = options['growl_password']
-      @growler.add_notification options['growl_notification_name']
+      @growler = Growl.new interpolated['growl_server'], interpolated['growl_app_name'], "GNTP"
+      @growler.password = interpolated['growl_password']
+      @growler.add_notification interpolated['growl_notification_name']
     end
     
     def notify_growl(subject, message)
-      @growler.notify(options['growl_notification_name'],subject,message)
+      @growler.notify(interpolated['growl_notification_name'], subject, message)
     end
 
     def receive(incoming_events)
@@ -51,7 +51,7 @@ module Agents
         message = (event.payload['message'] || event.payload['text']).to_s
         subject = event.payload['subject'].to_s
         if message.present? && subject.present?
-          log "Sending Growl notification '#{subject}': '#{message}' to #{options['growl_server']} with event #{event.id}"
+          log "Sending Growl notification '#{subject}': '#{message}' to #{interpolated(event.payload)['growl_server']} with event #{event.id}"
           notify_growl(subject,message)
         else
           log "Event #{event.id} not sent, message and subject expected"

+ 2 - 4
app/models/agents/hipchat_agent.rb

@@ -1,7 +1,5 @@
 module Agents
   class HipchatAgent < Agent
-    include LiquidInterpolatable
-
     cannot_be_scheduled!
     cannot_create_events!
 
@@ -42,9 +40,9 @@ module Agents
     end
 
     def receive(incoming_events)
-      client = HipChat::Client.new(options[:auth_token])
+      client = HipChat::Client.new(interpolated[:auth_token])
       incoming_events.each do |event|
-        mo = interpolate_options options, event.payload
+        mo = interpolated(event.payload)
         client[mo[:room_name]].send(mo[:username], mo[:message], :notify => mo[:notify].to_s == 'true' ? 1 : 0, :color => mo[:color])
       end
     end

+ 6 - 8
app/models/agents/human_task_agent.rb

@@ -2,8 +2,6 @@ require 'rturk'
 
 module Agents
   class HumanTaskAgent < Agent
-    include LiquidInterpolatable
-
     default_schedule "every_10m"
 
     description <<-MD
@@ -204,20 +202,20 @@ module Agents
     end
 
     def working?
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
     end
 
     def check
       review_hits
 
-      if options['trigger_on'] == "schedule" && (memory['last_schedule'] || 0) <= Time.now.to_i - options['submission_period'].to_i * 60 * 60
+      if interpolated['trigger_on'] == "schedule" && (memory['last_schedule'] || 0) <= Time.now.to_i - interpolated['submission_period'].to_i * 60 * 60
         memory['last_schedule'] = Time.now.to_i
         create_basic_hit
       end
     end
 
     def receive(incoming_events)
-      if options['trigger_on'] == "event"
+      if interpolated['trigger_on'] == "event"
         incoming_events.each do |event|
           create_basic_hit event
         end
@@ -227,11 +225,11 @@ module Agents
     protected
 
     def take_majority?
-      options['combination_mode'] == "take_majority" || options['take_majority'] == "true"
+      interpolated['combination_mode'] == "take_majority" || interpolated['take_majority'] == "true"
     end
 
     def create_poll?
-      options['combination_mode'] == "poll"
+      interpolated['combination_mode'] == "poll"
     end
 
     def event_for_hit(hit_id)
@@ -367,7 +365,7 @@ module Agents
     end
 
     def all_questions_are_numeric?
-      options['hit']['questions'].all? do |question|
+      interpolated['hit']['questions'].all? do |question|
         question['selections'].all? do |selection|
           selection['key'] == selection['key'].to_f.to_s || selection['key'] == selection['key'].to_i.to_s
         end

+ 7 - 7
app/models/agents/imap_folder_agent.rb

@@ -111,7 +111,7 @@ module Agents
     }
 
     def working?
-      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
     end
 
     def default_options
@@ -240,7 +240,7 @@ module Agents
         matched_part = nil
         matches = {}
 
-        options['conditions'].all? { |key, value|
+        interpolated['conditions'].all? { |key, value|
           case key
           when 'subject'
             value.present? or next true
@@ -308,7 +308,7 @@ module Agents
           notified << mail.message_id if mail.message_id
         end
 
-        if options['mark_as_read']
+        if interpolated['mark_as_read']
           log 'Marking as read'
           mail.mark_as_read
         end
@@ -322,14 +322,14 @@ module Agents
     end
 
     def each_unread_mail
-      host, port, ssl, username = options.values_at(:host, :port, :ssl, :username)
+      host, port, ssl, username = interpolated.values_at(:host, :port, :ssl, :username)
 
       log "Connecting to #{host}#{':%d' % port if port}#{' via SSL' if ssl}"
       Client.open(host, Integer(port), ssl) { |imap|
         log "Logging in as #{username}"
-        imap.login(username, options[:password])
+        imap.login(username, interpolated[:password])
 
-        options['folders'].each { |folder|
+        interpolated['folders'].each { |folder|
           log "Selecting the folder: %s" % folder
 
           imap.select(folder)
@@ -351,7 +351,7 @@ module Agents
     end
 
     def mime_types
-      options['mime_types'] || %w[text/plain text/enriched text/html]
+      interpolated['mime_types'] || %w[text/plain text/enriched text/html]
     end
 
     private

+ 7 - 9
app/models/agents/jabber_agent.rb

@@ -1,7 +1,5 @@
 module Agents
   class JabberAgent < Agent
-    include LiquidInterpolatable
-
     cannot_be_scheduled!
     cannot_create_events!
 
@@ -30,12 +28,12 @@ module Agents
     end
 
     def working?
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
     end
 
     def receive(incoming_events)
       incoming_events.each do |event|
-        log "Sending IM to #{options['jabber_receiver']} with event #{event.id}"
+        log "Sending IM to #{interpolated['jabber_receiver']} with event #{event.id}"
         deliver body(event)
       end
     end
@@ -45,15 +43,15 @@ module Agents
     end
 
     def deliver(text)
-      client.send Jabber::Message::new(options['jabber_receiver'], text).set_type(:chat)
+      client.send Jabber::Message::new(interpolated['jabber_receiver'], text).set_type(:chat)
     end
 
     private
 
     def client
-      Jabber::Client.new(Jabber::JID::new(options['jabber_sender'])).tap do |sender|
-        sender.connect(options['jabber_server'], (options['jabber_port'] || '5222'))
-        sender.auth(options['jabber_password'])
+      Jabber::Client.new(Jabber::JID::new(interpolated['jabber_sender'])).tap do |sender|
+        sender.connect(interpolated['jabber_server'], interpolated['jabber_port'] || '5222')
+        sender.auth interpolated['jabber_password']
       end
     end
 
@@ -62,7 +60,7 @@ module Agents
     end
 
     def body(event)
-      interpolate_string(options['message'], event.payload)
+      interpolated(event.payload)['message']
     end
   end
 end

+ 7 - 7
app/models/agents/java_script_agent.rb

@@ -35,12 +35,12 @@ module Agents
     def working?
       return false if recent_error_logs?
 
-      if options['expected_update_period_in_days'].present?
-        return false unless event_created_within?(options['expected_update_period_in_days'])
+      if interpolated['expected_update_period_in_days'].present?
+        return false unless event_created_within?(interpolated['expected_update_period_in_days'])
       end
 
-      if options['expected_receive_period_in_days'].present?
-        return false unless last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago
+      if interpolated['expected_receive_period_in_days'].present?
+        return false unless last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago
       end
 
       true
@@ -92,7 +92,7 @@ module Agents
 
       context["doCreateEvent"] = lambda { |a, y| create_event(payload: clean_nans(JSON.parse(y))).payload.to_json }
       context["getIncomingEvents"] = lambda { |a| incoming_events.to_json }
-      context["getOptions"] = lambda { |a, x| options.to_json }
+      context["getOptions"] = lambda { |a, x| interpolated.to_json }
       context["doLog"] = lambda { |a, x| log x }
       context["doError"] = lambda { |a, x| error x }
       context["getMemory"] = lambda do |a, x, y|
@@ -112,12 +112,12 @@ module Agents
       if cred
         credential(cred) || 'Agent.check = function() { this.error("Unable to find credential"); };'
       else
-        options['code']
+        interpolated['code']
       end
     end
 
     def credential_referenced_by_code
-      options['code'] =~ /\Acredential:(.*)\Z/ && $1
+      interpolated['code'] =~ /\Acredential:(.*)\Z/ && $1
     end
 
     def setup_javascript

+ 8 - 8
app/models/agents/jira_agent.rb

@@ -56,7 +56,7 @@ module Agents
     end
 
     def working?
-      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
     end
 
     def check
@@ -81,14 +81,14 @@ module Agents
 
   private
     def request_url(jql, start_at)
-      "#{options[:jira_url]}/rest/api/2/search?jql=#{CGI::escape(jql)}&fields=*all&startAt=#{start_at}"
+      "#{interpolated[:jira_url]}/rest/api/2/search?jql=#{CGI::escape(jql)}&fields=*all&startAt=#{start_at}"
     end
 
     def request_options
       ropts = {:headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)"}}
 
-      if !options[:username].empty?
-        ropts = ropts.merge({:basic_auth => {:username =>options[:username], :password=>options[:password]}})
+      if !interpolated[:username].empty?
+        ropts = ropts.merge({:basic_auth => {:username =>interpolated[:username], :password=>interpolated[:password]}})
       end
 
       ropts
@@ -121,10 +121,10 @@ module Agents
 
       jql = ""
 
-      if !options[:jql].empty? && since
-        jql = "(#{options[:jql]}) and updated >= '#{since.strftime('%Y-%m-%d %H:%M')}'"
+      if !interpolated[:jql].empty? && since
+        jql = "(#{interpolated[:jql]}) and updated >= '#{since.strftime('%Y-%m-%d %H:%M')}'"
       else
-        jql = options[:jql] if !options[:jql].empty?
+        jql = interpolated[:jql] if !interpolated[:jql].empty?
         jql = "updated >= '#{since.strftime('%Y-%m-%d %H:%M')}'" if since
       end
 
@@ -142,7 +142,7 @@ module Agents
           raise RuntimeError.new("There is no progress while fetching issues")
         end
 
-        if Time.now > start_time + options['timeout'].to_i * 60
+        if Time.now > start_time + interpolated['timeout'].to_i * 60
           raise RuntimeError.new("Timeout exceeded while fetching issues")
         end
 

+ 11 - 11
app/models/agents/mqtt_agent.rb

@@ -68,13 +68,13 @@ module Agents
 
     def validate_options
       unless options['uri'].present? &&
-        options['topic'].present?
+             options['topic'].present?
         errors.add(:base, "topic and uri are required")
       end
     end
 
     def working?
-      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
     end
 
     def default_options
@@ -91,13 +91,13 @@ module Agents
     end
 
     def mqtt_client
-      @client ||= MQTT::Client.new(options['uri'])
+      @client ||= MQTT::Client.new(interpolated['uri'])
 
-      if options['ssl']
-        @client.ssl = options['ssl'].to_sym
-        @client.ca_file = options['ca_file']
-        @client.cert_file = options['cert_file']
-        @client.key_file = options['key_file']
+      if interpolated['ssl']
+        @client.ssl = interpolated['ssl'].to_sym
+        @client.ca_file = interpolated['ca_file']
+        @client.cert_file = interpolated['cert_file']
+        @client.key_file = interpolated['key_file']
       end
 
       @client
@@ -106,7 +106,7 @@ module Agents
     def receive(incoming_events)
       mqtt_client.connect do |c|
         incoming_events.each do |event|
-          c.publish(options['topic'], payload)
+          c.publish(interpolated(event.payload)['topic'], event.payload)
         end
 
         c.disconnect
@@ -117,8 +117,8 @@ module Agents
     def check
       mqtt_client.connect do |c|
 
-        Timeout::timeout((options['max_read_time'].presence || 15).to_i) {
-          c.get(options['topic']) do |topic, message|
+        Timeout::timeout((interpolated['max_read_time'].presence || 15).to_i) {
+          c.get(interpolated['topic']) do |topic, message|
 
             # A lot of services generate JSON. Try that first
             payload = JSON.parse(message) rescue message

+ 11 - 13
app/models/agents/peak_detector_agent.rb

@@ -2,8 +2,6 @@ require 'pp'
 
 module Agents
   class PeakDetectorAgent < Agent
-    include LiquidInterpolatable
-
     cannot_be_scheduled!
 
     description <<-MD
@@ -45,7 +43,7 @@ module Agents
     end
 
     def working?
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
     end
 
     def receive(incoming_events)
@@ -69,7 +67,7 @@ module Agents
         if newest_value > average_value + std_multiple * standard_deviation
           memory['peaks'][group] << newest_time
           memory['peaks'][group].reject! { |p| p <= newest_time - window_duration }
-          create_event :payload => { 'message' => interpolate_string(options['message'], event.payload), 'peak' => newest_value, 'peak_time' => newest_time, 'grouped_by' => group.to_s }
+          create_event :payload => { 'message' => interpolated(event.payload)['message'], 'peak' => newest_value, 'peak_time' => newest_time, 'grouped_by' => group.to_s }
         end
       end
     end
@@ -94,33 +92,33 @@ module Agents
     end
 
     def window_duration
-      if options['window_duration'].present? # The older option
-        options['window_duration'].to_i
+      if interpolated['window_duration'].present? # The older option
+        interpolated['window_duration'].to_i
       else
-        (options['window_duration_in_days'] || 14).to_f.days
+        (interpolated['window_duration_in_days'] || 14).to_f.days
       end
     end
 
     def std_multiple
-      (options['std_multiple'] || 3).to_f
+      (interpolated['std_multiple'] || 3).to_f
     end
 
     def peak_spacing
-      if options['peak_spacing'].present? # The older option
-        options['peak_spacing'].to_i
+      if interpolated['peak_spacing'].present? # The older option
+        interpolated['peak_spacing'].to_i
       else
-        (options['min_peak_spacing_in_days'] || 2).to_f.days
+        (interpolated['min_peak_spacing_in_days'] || 2).to_f.days
       end
     end
 
     def group_for(event)
-      ((options['group_by_path'].present? && Utils.value_at(event.payload, options['group_by_path'])) || 'no_group')
+      ((interpolated['group_by_path'].present? && Utils.value_at(event.payload, interpolated['group_by_path'])) || 'no_group')
     end
 
     def remember(group, event)
       memory['data'] ||= {}
       memory['data'][group] ||= []
-      memory['data'][group] << [ Utils.value_at(event.payload, options['value_path']), event.created_at.to_i ]
+      memory['data'][group] << [ Utils.value_at(event.payload, interpolated['value_path']), event.created_at.to_i ]
       cleanup group
     end
 

+ 6 - 6
app/models/agents/post_agent.rb

@@ -27,15 +27,15 @@ module Agents
     end
 
     def working?
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
     end
 
     def method
-      (options['method'].presence || 'post').to_s.downcase
+      (interpolated['method'].presence || 'post').to_s.downcase
     end
 
     def headers
-      options['headers'].presence || {}
+      interpolated['headers'].presence || {}
     end
 
     def validate_options
@@ -58,16 +58,16 @@ module Agents
 
     def receive(incoming_events)
       incoming_events.each do |event|
-        handle (options['payload'].presence || {}).merge(event.payload)
+        handle (interpolated(event.payload)['payload'].presence || {}).merge(event.payload)
       end
     end
 
     def check
-      handle options['payload'].presence || {}
+      handle interpolated['payload'].presence || {}
     end
 
     def generate_uri(params = nil)
-      uri = URI options[:post_url]
+      uri = URI interpolated[:post_url]
       uri.query = URI.encode_www_form(Hash[URI.decode_www_form(uri.query || '')].merge(params)) if params
       uri
     end

+ 4 - 4
app/models/agents/public_transport_agent.rb

@@ -48,12 +48,12 @@ module Agents
     MD
 
     def check_url
-      stop_query = URI.encode(options["stops"].collect{|a| "&stops=#{a}"}.join)
-      "http://webservices.nextbus.com/service/publicXMLFeed?command=predictionsForMultiStops&a=#{options["agency"]}#{stop_query}"
+      stop_query = URI.encode(interpolated["stops"].collect{|a| "&stops=#{a}"}.join)
+      "http://webservices.nextbus.com/service/publicXMLFeed?command=predictionsForMultiStops&a=#{interpolated["agency"]}#{stop_query}"
     end
 
     def stops
-      options["stops"].collect{|a| a.split("|").last}
+      interpolated["stops"].collect{|a| a.split("|").last}
     end
 
     def check
@@ -65,7 +65,7 @@ module Agents
         predictions.each do |pr|
           parent = pr.parent.parent
           vals = {"routeTitle" => parent["routeTitle"], "stopTag" => parent["stopTag"]}
-          if pr["minutes"] && pr["minutes"].to_i < options["alert_window_in_minutes"].to_i
+          if pr["minutes"] && pr["minutes"].to_i < interpolated["alert_window_in_minutes"].to_i
             vals = vals.merge Hash.from_xml(pr.to_xml)
             if not_already_in_memory?(vals)
               create_event(:payload => vals)

+ 3 - 4
app/models/agents/pushbullet_agent.rb

@@ -1,7 +1,5 @@
 module Agents
   class PushbulletAgent < Agent
-    include LiquidInterpolatable
-
     cannot_be_scheduled!
     cannot_create_events!
 
@@ -49,10 +47,11 @@ module Agents
     end
 
     private
+
     def query_options(event)
-      mo = interpolate_options options, event.payload
+      mo = interpolated(event.payload)
       {
-        :basic_auth => {:username =>mo[:api_key], :password=>''},
+        :basic_auth => {:username => mo[:api_key], :password => ''},
         :body => {:device_iden => mo[:device_id], :title => mo[:title], :body => mo[:body], :type => 'note'}
       }
     end

+ 30 - 30
app/models/agents/pushover_agent.rb

@@ -19,13 +19,13 @@ module Agents
       Your event can provide any of the following optional parameters or you can provide defaults:
 
       * `device` - your user's device name to send the message directly to that device, rather than all of the user's devices
-      * `title` or `subject` - your notifications's title
+      * `title` or `subject` - your notification's title
       * `url` - a supplementary URL to show with your message - `512` Character Limit
       * `url_title` - a title for your supplementary URL, otherwise just the URL is shown - `100` Character Limit
       * `priority` - send as `-1` to always send as a quiet notification, `0` is default, `1` to display as high-priority and bypass the user's quiet hours, or `2` for emergency priority: [Please read Pushover Docs on Emergency Priority](https://pushover.net/api#priority)
       * `sound` - the name of one of the sounds supported by device clients to override the user's default sound choice. [See PushOver docs for sound options.](https://pushover.net/api#sounds)
-      * `retry` - Requred for emergency priority - Specifies how often (in seconds) the Pushover servers will send the same notification to the user. Minimum value: `30`
-      * `expire` - Requred for emergency priority - Specifies how many seconds your notification will continue to be retried for (every retry seconds). Maximum value: `86400`
+      * `retry` - Required for emergency priority - Specifies how often (in seconds) the Pushover servers will send the same notification to the user. Minimum value: `30`
+      * `expire` - Required for emergency priority - Specifies how many seconds your notification will continue to be retried for (every retry seconds). Maximum value: `86400`
 
       Your event can also pass along a timestamp parameter:
 
@@ -42,10 +42,10 @@ module Agents
         'title' => '',
         'url' => '',
         'url_title' => '',
-        'priority' => 0,
+        'priority' => '0',
         'sound' => 'pushover',
-        'retry' => 0,
-        'expire' => 0,
+        'retry' => '0',
+        'expire' => '0',
         'expected_receive_period_in_days' => '1'
       }
     end
@@ -58,50 +58,50 @@ module Agents
 
     def receive(incoming_events)
       incoming_events.each do |event|
-        message = (event.payload['message'].presence  || event.payload['text'].presence  || options['message']).to_s
+        payload_interpolated = interpolated(event.payload)
+        message = (event.payload['message'].presence || event.payload['text'].presence || payload_interpolated['message']).to_s
         if message.present?
-            post_params = {
-              'token' => options['token'],
-              'user' => options['user'],
-              'message' => message
-            }
+          post_params = {
+            'token' => payload_interpolated['token'],
+            'user' => payload_interpolated['user'],
+            'message' => message
+          }
 
-            post_params['device'] = event.payload['device'].presence  || options['device']
-            post_params['title'] = event.payload['title'].presence  || event.payload['subject'].presence  || options['title']
+          post_params['device'] = event.payload['device'].presence || payload_interpolated['device']
+          post_params['title'] = event.payload['title'].presence || event.payload['subject'].presence || payload_interpolated['title']
 
-            url = (event.payload['url'].presence  || options['url'] || '').to_s
-            url = url.slice 0..512
-            post_params['url'] = url
+          url = (event.payload['url'].presence || payload_interpolated['url'] || '').to_s
+          url = url.slice 0..512
+          post_params['url'] = url
 
-            url_title = (event.payload['url_title'].presence  || options['url_title']).to_s
-            url_title = url_title.slice 0..100
-            post_params['url_title'] = url_title
+          url_title = (event.payload['url_title'].presence || payload_interpolated['url_title']).to_s
+          url_title = url_title.slice 0..100
+          post_params['url_title'] = url_title
 
-            post_params['priority'] = (event.payload['priority'].presence  || options['priority']).to_i
+          post_params['priority'] = (event.payload['priority'].presence || payload_interpolated['priority']).to_i
 
-            if event.payload.has_key? 'timestamp'
-              post_params['timestamp'] = (event.payload['timestamp']).to_s
-            end
+          if event.payload.has_key? 'timestamp'
+            post_params['timestamp'] = (event.payload['timestamp']).to_s
+          end
 
-            post_params['sound'] = (event.payload['sound'].presence  || options['sound']).to_s
+          post_params['sound'] = (event.payload['sound'].presence || payload_interpolated['sound']).to_s
 
-            post_params['retry'] = (event.payload['retry'].presence  || options['retry']).to_i
+          post_params['retry'] = (event.payload['retry'].presence || payload_interpolated['retry']).to_i
 
-            post_params['expire'] = (event.payload['expire'].presence  || options['expire']).to_i
+          post_params['expire'] = (event.payload['expire'].presence || payload_interpolated['expire']).to_i
 
-            send_notification(post_params)
+          send_notification(post_params)
         end
       end
     end
 
     def working?
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
     end
 
     def send_notification(post_params)
       response = HTTParty.post(API_URL, :query => post_params)
       puts response
     end
-
   end
 end

+ 2 - 2
app/models/agents/sentiment_agent.rb

@@ -34,13 +34,13 @@ module Agents
     end
 
     def working?
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
     end
 
     def receive(incoming_events)
       anew = self.class.sentiment_hash
       incoming_events.each do |event|
-        Utils.values_at(event.payload, options['content']).each do |content|
+        Utils.values_at(event.payload, interpolated['content']).each do |content|
           sent_values = sentiment_values anew, content
           create_event :payload => { 'content' => content,
                                      'valence' => sent_values[0],

+ 8 - 7
app/models/agents/shell_command_agent.rb

@@ -15,7 +15,8 @@ module Agents
 
       `expected_update_period_in_days` is used to determine if the Agent is working.
 
-      ShellCommandAgent can also act upon received events. These events may contain their own `path` and `command` values. If they do not, ShellCommandAgent will use the configured options. For this reason, please specify defaults even if you are planning to have this Agent to respond to events.
+      ShellCommandAgent can also act upon received events. When receiving an event, this Agent's options can interpolate values from the incoming event.
+      For example, your command could be defined as `{{cmd}}`, in which case the event's `cmd` property would be used.
 
       The resulting event will contain the `command` which was executed, the `path` it was executed under, the `exit_status` of the command, the `errors`, and the actual `output`. ShellCommandAgent will not log an error if the result implies that something went wrong.
 
@@ -55,25 +56,25 @@ module Agents
     end
 
     def working?
-      Agents::ShellCommandAgent.should_run? && event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
+      Agents::ShellCommandAgent.should_run? && event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
     end
 
     def receive(incoming_events)
       incoming_events.each do |event|
-        handle(event.payload, event)
+        handle(interpolated(event.payload), event)
       end
     end
 
     def check
-      handle(options)
+      handle(interpolated)
     end
 
     private
 
-    def handle(opts = options, event = nil)
+    def handle(opts, event = nil)
       if Agents::ShellCommandAgent.should_run?
-        command = opts['command'] || options['command']
-        path = opts['path'] || options['path']
+        command = opts['command']
+        path = opts['path']
 
         result, errors, exit_status = run_command(path, command)
 

+ 4 - 5
app/models/agents/slack_agent.rb

@@ -1,6 +1,5 @@
 module Agents
   class SlackAgent < Agent
-    include LiquidInterpolatable
     cannot_be_scheduled!
     cannot_create_events!
 
@@ -45,20 +44,20 @@ module Agents
     end
 
     def webhook
-      options[:webhook].presence || DEFAULT_WEBHOOK
+      interpolated[:webhook].presence || DEFAULT_WEBHOOK
     end
 
     def username
-      options[:username].presence || DEFAULT_USERNAME
+      interpolated[:username].presence || DEFAULT_USERNAME
     end
 
     def slack_notifier
-      @slack_notifier ||= Slack::Notifier.new(options[:team_name], options[:auth_token], webhook, username: username)
+      @slack_notifier ||= Slack::Notifier.new(interpolated[:team_name], interpolated[:auth_token], webhook, username: username)
     end
 
     def receive(incoming_events)
       incoming_events.each do |event|
-        opts = interpolate_options options, event.payload
+        opts = interpolated(event.payload)
         slack_notifier.ping opts[:message], channel: opts[:channel], username: opts[:username]
       end
     end

+ 1 - 1
app/models/agents/stubhub_agent.rb

@@ -35,7 +35,7 @@ module Agents
     end
 
     def url
-      options['url']
+      interpolated['url']
     end
 
     def check

+ 6 - 8
app/models/agents/translation_agent.rb

@@ -1,7 +1,5 @@
 module Agents
   class TranslationAgent < Agent
-    include LiquidInterpolatable
-
     cannot_be_scheduled!
 
     description <<-MD
@@ -30,7 +28,7 @@ module Agents
     end
 
     def working?
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
     end
 
     def translate(text, to, access_token)
@@ -61,16 +59,16 @@ module Agents
 
     def receive(incoming_events)
       auth_uri = URI "https://datamarket.accesscontrol.windows.net/v2/OAuth2-13"
-      response = postform auth_uri, :client_id => options['client_id'],
-                                    :client_secret => options['client_secret'],
+      response = postform auth_uri, :client_id => interpolated['client_id'],
+                                    :client_secret => interpolated['client_secret'],
                                     :scope => "http://api.microsofttranslator.com",
                                     :grant_type => "client_credentials"
       access_token = JSON.parse(response.body)["access_token"]
       incoming_events.each do |event|
         translated_event = {}
-        options['content'].each_pair do |key, value|
-          to_be_translated = interpolate_string(value, event.payload)
-          translated_event[key] = translate(to_be_translated.first, options['to'], access_token)
+        opts = interpolated(event.payload)
+        opts['content'].each_pair do |key, value|
+          translated_event[key] = translate(value.first, opts['to'], access_token)
         end
         create_event :payload => translated_event
       end

+ 9 - 8
app/models/agents/trigger_agent.rb

@@ -1,7 +1,5 @@
 module Agents
   class TriggerAgent < Agent
-    include LiquidInterpolatable
-
     cannot_be_scheduled!
 
     VALID_COMPARISON_TYPES = %w[regex !regex field<value field<=value field==value field!=value field>=value field>value]
@@ -30,7 +28,7 @@ module Agents
 
     def validate_options
       unless options['expected_receive_period_in_days'].present? && options['rules'].present? &&
-          options['rules'].all? { |rule| rule['type'].present? && VALID_COMPARISON_TYPES.include?(rule['type']) && rule['value'].present? && rule['path'].present? }
+             options['rules'].all? { |rule| rule['type'].present? && VALID_COMPARISON_TYPES.include?(rule['type']) && rule['value'].present? && rule['path'].present? }
         errors.add(:base, "expected_receive_period_in_days, message, and rules, with a type, value, and path for every rule, are required")
       end
 
@@ -53,12 +51,15 @@ module Agents
     end
 
     def working?
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
     end
 
     def receive(incoming_events)
       incoming_events.each do |event|
-        match = options['rules'].all? do |rule|
+
+        opts = interpolated(event.payload)
+
+        match = opts['rules'].all? do |rule|
           value_at_path = Utils.value_at(event['payload'], rule['path'])
           rule_values = rule['value']
           rule_values = [rule_values] unless rule_values.is_a?(Array)
@@ -90,9 +91,9 @@ module Agents
         if match
           if keep_event?
             payload = event.payload.dup
-            payload['message'] = interpolate_string(options['message'], event.payload) if options['message'].present?
+            payload['message'] = opts['message'] if opts['message'].present?
           else
-            payload = { 'message' => interpolate_string(options['message'], event.payload) }
+            payload = { 'message' => opts['message'] }
           end
 
           create_event :payload => payload
@@ -101,7 +102,7 @@ module Agents
     end
 
     def keep_event?
-      options['keep_event'] == 'true'
+      interpolated['keep_event'] == 'true'
     end
   end
 end

+ 9 - 9
app/models/agents/twilio_agent.rb

@@ -39,18 +39,18 @@ module Agents
     end
 
     def receive(incoming_events)
-      @client = Twilio::REST::Client.new options['account_sid'], options['auth_token']
+      @client = Twilio::REST::Client.new interpolated['account_sid'], interpolated['auth_token']
       memory['pending_calls'] ||= {}
       incoming_events.each do |event|
         message = (event.payload['message'].presence || event.payload['text'].presence || event.payload['sms'].presence).to_s
         if message.present?
-          if options['receive_call'].to_s == 'true'
+          if interpolated(event.payload)['receive_call'].to_s == 'true'
             secret = SecureRandom.hex 3
             memory['pending_calls'][secret] = message
             make_call secret
           end
 
-          if options['receive_text'].to_s == 'true'
+          if interpolated(event.payload)['receive_text'].to_s == 'true'
             message = message.slice 0..160
             send_message message
           end
@@ -59,19 +59,19 @@ module Agents
     end
 
     def working?
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
     end
 
     def send_message(message)
-      @client.account.sms.messages.create :from => options['sender_cell'],
-                                          :to => options['receiver_cell'],
+      @client.account.sms.messages.create :from => interpolated['sender_cell'],
+                                          :to => interpolated['receiver_cell'],
                                           :body => message
     end
 
     def make_call(secret)
-      @client.account.calls.create :from => options['sender_cell'],
-                                   :to => options['receiver_cell'],
-                                   :url => post_url(options['server_url'], secret)
+      @client.account.calls.create :from => interpolated['sender_cell'],
+                                   :to => interpolated['receiver_cell'],
+                                   :url => post_url(interpolated['server_url'], secret)
     end
 
     def post_url(server_url, secret)

+ 2 - 3
app/models/agents/twitter_publish_agent.rb

@@ -3,7 +3,6 @@ require "twitter"
 module Agents
   class TwitterPublishAgent < Agent
     include TwitterConcern
-    include LiquidInterpolatable
 
     cannot_be_scheduled!
 
@@ -22,7 +21,7 @@ module Agents
     end
 
     def working?
-      event_created_within?(options['expected_update_period_in_days']) && most_recent_event && most_recent_event.payload['success'] == true && !recent_error_logs?
+      event_created_within?(interpolated['expected_update_period_in_days']) && most_recent_event && most_recent_event.payload['success'] == true && !recent_error_logs?
     end
 
     def default_options
@@ -38,7 +37,7 @@ module Agents
         incoming_events = incoming_events.first(20)
       end
       incoming_events.each do |event|
-        tweet_text = interpolate_string(options['message'], event.payload)
+        tweet_text = interpolated(event.payload)['message']
         begin
           tweet = publish_tweet tweet_text
           create_event :payload => {

+ 5 - 5
app/models/agents/twitter_stream_agent.rb

@@ -61,7 +61,7 @@ module Agents
     end
 
     def working?
-      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
     end
 
     def default_options
@@ -76,7 +76,7 @@ module Agents
       filter = lookup_filter(filter)
 
       if filter
-        if options['generate'] == "counts"
+        if interpolated['generate'] == "counts"
           # Avoid memory pollution by reloading the Agent.
           agent = Agent.find(id)
           agent.memory['filter_counts'] ||= {}
@@ -91,7 +91,7 @@ module Agents
     end
 
     def check
-      if options['generate'] == "counts" && memory['filter_counts'] && memory['filter_counts'].length > 0
+      if interpolated['generate'] == "counts" && memory['filter_counts'] && memory['filter_counts'].length > 0
         memory['filter_counts'].each do |filter, count|
           create_event :payload => { 'filter' => filter, 'count' => count, 'time' => Time.now.to_i }
         end
@@ -102,7 +102,7 @@ module Agents
     protected
 
     def lookup_filter(filter)
-      options['filters'].each do |known_filter|
+      interpolated['filters'].each do |known_filter|
         if known_filter == filter
           return filter
         elsif known_filter.is_a?(Array)
@@ -115,7 +115,7 @@ module Agents
 
     def remove_unused_keys!(agent, base)
       if agent.memory[base]
-        (agent.memory[base].keys - agent.options['filters'].map {|f| f.is_a?(Array) ? f.first.to_s : f.to_s }).each do |removed_key|
+        (agent.memory[base].keys - agent.interpolated['filters'].map {|f| f.is_a?(Array) ? f.first.to_s : f.to_s }).each do |removed_key|
           agent.memory[base].delete(removed_key)
         end
       end

+ 5 - 5
app/models/agents/twitter_user_agent.rb

@@ -47,7 +47,7 @@ module Agents
     default_schedule "every_1h"
 
     def working?
-      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
     end
 
     def default_options
@@ -72,15 +72,15 @@ module Agents
     end
 
     def starting_at
-      if options[:starting_at].present?
-        Time.parse(options[:starting_at]) rescue created_at
+      if interpolated[:starting_at].present?
+        Time.parse(interpolated[:starting_at]) rescue created_at
       else
         created_at
       end
     end
 
     def include_retweets?
-      options[:include_retweets] != "false"
+      interpolated[:include_retweets] != "false"
     end
 
     def check
@@ -89,7 +89,7 @@ module Agents
       opts.merge! :since_id => since_id unless since_id.nil?
 
       # http://rdoc.info/gems/twitter/Twitter/REST/Timelines#user_timeline-instance_method
-      tweets = twitter.user_timeline(options['username'], opts)
+      tweets = twitter.user_timeline(interpolated['username'], opts)
 
       tweets.each do |tweet|
         if tweet.created_at >= starting_at

+ 7 - 7
app/models/agents/weather_agent.rb

@@ -51,11 +51,11 @@ module Agents
     default_schedule "8pm"
 
     def working?
-      event_created_within?((options['expected_update_period_in_days'].presence || 2).to_i) && !recent_error_logs?
+      event_created_within?((interpolated['expected_update_period_in_days'].presence || 2).to_i) && !recent_error_logs?
     end
 
     def key_setup?
-      options['api_key'].present? && options['api_key'] != "your-key"
+      interpolated['api_key'].present? && interpolated['api_key'] != "your-key"
     end
 
     def default_options
@@ -69,15 +69,15 @@ module Agents
     end
 
     def service
-      options["service"].presence || "wunderground"
+      interpolated["service"].presence || "wunderground"
     end
 
     def which_day
-      (options["which_day"].presence || 1).to_i
+      (interpolated["which_day"].presence || 1).to_i
     end
 
     def location
-      options["location"].presence || options["zipcode"]
+      interpolated["location"].presence || interpolated["zipcode"]
     end
 
     def validate_options
@@ -89,12 +89,12 @@ module Agents
     end
 
     def wunderground
-      Wunderground.new(options['api_key']).forecast_for(location)['forecast']['simpleforecast']['forecastday'] if key_setup?
+      Wunderground.new(interpolated['api_key']).forecast_for(location)['forecast']['simpleforecast']['forecastday'] if key_setup?
     end
 
     def forecastio
       if key_setup?
-        ForecastIO.api_key = options['api_key']
+        ForecastIO.api_key = interpolated['api_key']
         lat, lng = location.split(',')
         ForecastIO.forecast(lat,lng)['daily']['data']
       end

+ 4 - 4
app/models/agents/webhook_agent.rb

@@ -27,7 +27,7 @@ module Agents
     event_description do
       <<-MD
         The event payload is base on the value of the `payload_path` option,
-        which is set to `#{options['payload_path']}`.
+        which is set to `#{interpolated['payload_path']}`.
       MD
     end
 
@@ -40,7 +40,7 @@ module Agents
     def receive_web_request(params, method, format)
       secret = params.delete('secret')
       return ["Please use POST requests only", 401] unless method == "post"
-      return ["Not Authorized", 401] unless secret == options['secret']
+      return ["Not Authorized", 401] unless secret == interpolated['secret']
 
       create_event(:payload => payload_for(params))
 
@@ -48,7 +48,7 @@ module Agents
     end
 
     def working?
-      event_created_within?(options['expected_receive_period_in_days']) && !recent_error_logs?
+      event_created_within?(interpolated['expected_receive_period_in_days']) && !recent_error_logs?
     end
 
     def validate_options
@@ -58,7 +58,7 @@ module Agents
     end
 
     def payload_for(params)
-      Utils.value_at(params, options['payload_path']) || {}
+      Utils.value_at(params, interpolated['payload_path']) || {}
     end
   end
 end

+ 22 - 22
app/models/agents/website_agent.rb

@@ -55,11 +55,11 @@ module Agents
     MD
 
     event_description do
-      "Events will have the fields you specified.  Your options look like:\n\n    #{Utils.pretty_print options['extract']}"
+      "Events will have the fields you specified.  Your options look like:\n\n    #{Utils.pretty_print interpolated['extract']}"
     end
 
     def working?
-      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
     end
 
     def default_options
@@ -125,7 +125,7 @@ module Agents
     end
 
     def check
-      check_url options['url']
+      check_url interpolated['url']
     end
 
     def check_url(in_url)
@@ -136,7 +136,7 @@ module Agents
         response = faraday.get(url)
         if response.success?
           body = response.body
-          if (encoding = options['force_encoding']).present?
+          if (encoding = interpolated['force_encoding']).present?
             body = body.encode(Encoding::UTF_8, encoding)
           end
           doc = parse(body)
@@ -148,7 +148,7 @@ module Agents
             end
           else
             output = {}
-            options['extract'].each do |name, extraction_details|
+            interpolated['extract'].each do |name, extraction_details|
               if extraction_type == "json"
                 result = Utils.values_at(doc, extraction_details['path'])
                 log "Extracting #{extraction_type} at #{extraction_details['path']}: #{result}"
@@ -181,17 +181,17 @@ module Agents
               output[name] = result
             end
 
-            num_unique_lengths = options['extract'].keys.map { |name| output[name].length }.uniq
+            num_unique_lengths = interpolated['extract'].keys.map { |name| output[name].length }.uniq
 
             if num_unique_lengths.length != 1
-              error "Got an uneven number of matches for #{options['name']}: #{options['extract'].inspect}"
+              error "Got an uneven number of matches for #{interpolated['name']}: #{interpolated['extract'].inspect}"
               return
             end
 
             old_events = previous_payloads num_unique_lengths.first
             num_unique_lengths.first.times do |index|
               result = {}
-              options['extract'].keys.each do |name|
+              interpolated['extract'].keys.each do |name|
                 result[name] = output[name][index]
                 if name.to_s == 'url'
                   result[name] = (response.env[:url] + result[name]).to_s
@@ -223,11 +223,11 @@ module Agents
     # If mode is set to 'on_change', this method may return false and update an existing
     # event to expire further in the future.
     def store_payload!(old_events, result)
-      if !options['mode'].present?
+      if !interpolated['mode'].present?
         return true
-      elsif options['mode'].to_s == "all"
+      elsif interpolated['mode'].to_s == "all"
         return true
-      elsif options['mode'].to_s == "on_change"
+      elsif interpolated['mode'].to_s == "on_change"
         result_json = result.to_json
         old_events.each do |old_event|
           if old_event.payload.to_json == result_json
@@ -238,12 +238,12 @@ module Agents
         end
         return true
       end
-      raise "Illegal options[mode]: " + options['mode'].to_s
+      raise "Illegal options[mode]: " + interpolated['mode'].to_s
     end
 
     def previous_payloads(num_events)
-      if options['uniqueness_look_back'].present?
-        look_back = options['uniqueness_look_back'].to_i
+      if interpolated['uniqueness_look_back'].present?
+        look_back = interpolated['uniqueness_look_back'].to_i
       else
         # Larger of UNIQUENESS_FACTOR * num_events and UNIQUENESS_LOOK_BACK
         look_back = UNIQUENESS_FACTOR * num_events
@@ -251,18 +251,18 @@ module Agents
           look_back = UNIQUENESS_LOOK_BACK
         end
       end
-      events.order("id desc").limit(look_back) if options['mode'].present? && options['mode'].to_s == "on_change"
+      events.order("id desc").limit(look_back) if interpolated['mode'].present? && interpolated['mode'].to_s == "on_change"
     end
 
     def extract_full_json?
-      !options['extract'].present? && extraction_type == "json"
+      !interpolated['extract'].present? && extraction_type == "json"
     end
 
     def extraction_type
-      (options['type'] || begin
-        if options['url'] =~ /\.(rss|xml)$/i
+      (interpolated['type'] || begin
+        if interpolated['url'] =~ /\.(rss|xml)$/i
           "xml"
-        elsif options['url'] =~ /\.json$/i
+        elsif interpolated['url'] =~ /\.json$/i
           "json"
         else
           "html"
@@ -295,7 +295,7 @@ module Agents
       @faraday ||= Faraday.new { |builder|
         builder.headers = headers if headers.length > 0
 
-        if (user_agent = options['user_agent']).present?
+        if (user_agent = interpolated['user_agent']).present?
           builder.headers[:user_agent] = user_agent
         end
 
@@ -318,7 +318,7 @@ module Agents
     end
 
     def basic_auth_credentials
-      case value = options['basic_auth']
+      case value = interpolated['basic_auth']
       when nil, ''
         return nil
       when Array
@@ -330,7 +330,7 @@ module Agents
     end
 
     def headers
-      options['headers'].presence || {}
+      interpolated['headers'].presence || {}
     end
   end
 end

+ 3 - 3
app/models/agents/weibo_publish_agent.rb

@@ -21,13 +21,13 @@ module Agents
 
     def validate_options
       unless options['uid'].present? &&
-        options['expected_update_period_in_days'].present?
+             options['expected_update_period_in_days'].present?
         errors.add(:base, "expected_update_period_in_days and uid are required")
       end
     end
 
     def working?
-      event_created_within?(options['expected_update_period_in_days']) && most_recent_event.payload['success'] == true && !recent_error_logs?
+      event_created_within?(interpolated['expected_update_period_in_days']) && most_recent_event.payload['success'] == true && !recent_error_logs?
     end
 
     def default_options
@@ -47,7 +47,7 @@ module Agents
         incoming_events = incoming_events.first(20)
       end
       incoming_events.each do |event|
-        tweet_text = Utils.value_at(event.payload, options['message_path'])
+        tweet_text = Utils.value_at(event.payload, interpolated(event.payload)['message_path'])
         if event.agent.type == "Agents::TwitterUserAgent"
           tweet_text = unwrap_tco_urls(tweet_text, event.payload)
         end

+ 3 - 3
app/models/agents/weibo_user_agent.rb

@@ -71,13 +71,13 @@ module Agents
 
     def validate_options
       unless options['uid'].present? &&
-        options['expected_update_period_in_days'].present?
+             options['expected_update_period_in_days'].present?
         errors.add(:base, "expected_update_period_in_days and uid are required")
       end
     end
 
     def working?
-      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
     end
 
     def default_options
@@ -92,7 +92,7 @@ module Agents
 
     def check
       since_id = memory['since_id'] || nil
-      opts = {:uid => options['uid'].to_i}
+      opts = {:uid => interpolated['uid'].to_i}
       opts.merge! :since_id => since_id unless since_id.nil?
 
       # http://open.weibo.com/wiki/2/statuses/user_timeline/en

+ 19 - 0
app/models/scenario.rb

@@ -0,0 +1,19 @@
+class Scenario < ActiveRecord::Base
+  include HasGuid
+
+  attr_accessible :name, :agent_ids, :description, :public, :source_url
+
+  belongs_to :user, :counter_cache => :scenario_count, :inverse_of => :scenarios
+  has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :scenario
+  has_many :agents, :through => :scenario_memberships, :inverse_of => :scenarios
+
+  validates_presence_of :name, :user
+
+  validate :agents_are_owned
+
+  protected
+
+  def agents_are_owned
+    errors.add(:agents, "must be owned by you") unless agents.all? {|s| s.user == user }
+  end
+end

+ 256 - 0
app/models/scenario_import.rb

@@ -0,0 +1,256 @@
+require 'ostruct'
+
+# This is a helper class for managing Scenario imports, used by the ScenarioImportsController.  This class behaves much
+# like a normal ActiveRecord object, with validations and callbacks.  However, it is never persisted to the database.
+class ScenarioImport
+  include ActiveModel::Model
+  include ActiveModel::Callbacks
+  include ActiveModel::Validations::Callbacks
+
+  DANGEROUS_AGENT_TYPES = %w[Agents::ShellCommandAgent]
+  URL_REGEX = /\Ahttps?:\/\//i
+
+  attr_accessor :file, :url, :data, :do_import, :merges
+
+  attr_reader :user
+
+  before_validation :parse_file
+  before_validation :fetch_url
+
+  validate :validate_presence_of_file_url_or_data
+  validates_format_of :url, :with => URL_REGEX, :allow_nil => true, :allow_blank => true, :message => "appears to be invalid"
+  validate :validate_data
+  validate :generate_diff
+
+  def step_one?
+    data.blank?
+  end
+
+  def step_two?
+    data.present?
+  end
+
+  def set_user(user)
+    @user = user
+  end
+
+  def existing_scenario
+    @existing_scenario ||= user.scenarios.find_by(:guid => parsed_data["guid"])
+  end
+
+  def dangerous?
+    (parsed_data['agents'] || []).any? { |agent| DANGEROUS_AGENT_TYPES.include?(agent['type']) }
+  end
+
+  def parsed_data
+    @parsed_data ||= (data && JSON.parse(data) rescue {}) || {}
+  end
+
+  def agent_diffs
+    @agent_diffs || generate_diff
+  end
+
+  def should_import?
+    do_import == "1"
+  end
+
+  def import(options = {})
+    success = true
+    guid = parsed_data['guid']
+    description = parsed_data['description']
+    name = parsed_data['name']
+    links = parsed_data['links']
+    source_url = parsed_data['source_url'].presence || nil
+    @scenario = user.scenarios.where(:guid => guid).first_or_initialize
+    @scenario.update_attributes!(:name => name, :description => description,
+                                 :source_url => source_url, :public => false)
+
+    unless options[:skip_agents]
+      created_agents = agent_diffs.map do |agent_diff|
+        agent = agent_diff.agent || Agent.build_for_type("Agents::" + agent_diff.type.incoming, user)
+        agent.guid = agent_diff.guid.incoming
+        agent.attributes = { :name => agent_diff.name.updated,
+                             :disabled => agent_diff.disabled.updated, # == "true"
+                             :options => agent_diff.options.updated,
+                             :scenario_ids => [@scenario.id] }
+        agent.schedule = agent_diff.schedule.updated if agent_diff.schedule.present?
+        agent.keep_events_for = agent_diff.keep_events_for.updated if agent_diff.keep_events_for.present?
+        agent.propagate_immediately = agent_diff.propagate_immediately.updated if agent_diff.propagate_immediately.present? # == "true"
+        unless agent.save
+          success = false
+          errors.add(:base, "Errors when saving '#{agent_diff.name.incoming}': #{agent.errors.full_messages.to_sentence}")
+        end
+        agent
+      end
+
+      links.each do |link|
+        receiver = created_agents[link['receiver']]
+        source = created_agents[link['source']]
+        receiver.sources << source unless receiver.sources.include?(source)
+      end
+    end
+
+    success
+  end
+
+  def scenario
+    @scenario || @existing_scenario
+  end
+
+  def will_request_local?(url_root)
+    data.blank? && file.blank? && url.present? && url.starts_with?(url_root)
+  end
+
+  protected
+
+  def parse_file
+    if data.blank? && file.present?
+      self.data = file.read
+    end
+  end
+
+  def fetch_url
+    if data.blank? && url.present? && url =~ URL_REGEX
+      self.data = Faraday.get(url).body
+    end
+  end
+
+  def validate_data
+    if data.present?
+      @parsed_data = JSON.parse(data) rescue {}
+      if (%w[name guid agents] - @parsed_data.keys).length > 0
+        errors.add(:base, "The provided data does not appear to be a valid Scenario.")
+        self.data = nil
+      end
+    else
+      @parsed_data = nil
+    end
+  end
+
+  def validate_presence_of_file_url_or_data
+    unless file.present? || url.present? || data.present?
+      errors.add(:base, "Please provide either a Scenario JSON File or a Public Scenario URL.")
+    end
+  end
+
+  def generate_diff
+    @agent_diffs = (parsed_data['agents'] || []).map.with_index do |agent_data, index|
+      # AgentDiff is defined at the end of this file.
+      agent_diff = AgentDiff.new(agent_data)
+      if existing_scenario
+        # If this Agent exists already, update the AgentDiff with the local version's information.
+        agent_diff.diff_with! existing_scenario.agents.find_by(:guid => agent_data['guid'])
+
+        begin
+          # Update the AgentDiff with any hand-merged changes coming from the UI.  This only happens when this
+          # Agent already exists locally and has conflicting changes.
+          agent_diff.update_from! merges[index.to_s] if merges
+        rescue JSON::ParserError
+          errors.add(:base, "Your updated options for '#{agent_data['name']}' were unparsable.")
+        end
+      end
+      agent_diff
+    end
+  end
+
+  # AgentDiff is a helper object that encapsulates an incoming Agent.  All fields will be returned as an array
+  # of either one or two values.  The first value is the incoming value, the second is the existing value, if
+  # it differs from the incoming value.
+  class AgentDiff < OpenStruct
+    class FieldDiff
+      attr_accessor :incoming, :current, :updated
+
+      def initialize(incoming)
+        @incoming = incoming
+        @updated = incoming
+      end
+
+      def set_current(current)
+        @current = current
+        @requires_merge = (incoming != current)
+      end
+
+      def requires_merge?
+        @requires_merge
+      end
+    end
+
+    def initialize(agent_data)
+      super()
+      @requires_merge = false
+      self.agent = nil
+      store! agent_data
+    end
+
+    BASE_FIELDS = %w[name schedule keep_events_for propagate_immediately disabled guid]
+
+    def agent_exists?
+      !!agent
+    end
+
+    def requires_merge?
+      @requires_merge
+    end
+
+    def store!(agent_data)
+      self.type = FieldDiff.new(agent_data["type"].split("::").pop)
+      self.options = FieldDiff.new(agent_data['options'] || {})
+      BASE_FIELDS.each do |option|
+        self[option] = FieldDiff.new(agent_data[option]) if agent_data.has_key?(option)
+      end
+    end
+
+    def diff_with!(agent)
+      return unless agent.present?
+
+      self.agent = agent
+
+      type.set_current(agent.short_type)
+      options.set_current(agent.options || {})
+
+      @requires_merge ||= type.requires_merge?
+      @requires_merge ||= options.requires_merge?
+
+      BASE_FIELDS.each do |field|
+        next unless self[field].present?
+        self[field].set_current(agent.send(field))
+
+        @requires_merge ||= self[field].requires_merge?
+      end
+    end
+
+    def update_from!(merges)
+      each_field do |field, value, selection_options|
+        value.updated = merges[field]
+      end
+
+      if options.requires_merge?
+        options.updated = JSON.parse(merges['options'])
+      end
+    end
+
+    def each_field
+      boolean = [["True", "true"], ["False", "false"]]
+      yield 'name', name if name.requires_merge?
+      yield 'schedule', schedule, Agent::SCHEDULES.map {|s| [s.humanize.titleize, s] } if self['schedule'].present? && schedule.requires_merge?
+      yield 'keep_events_for', keep_events_for, Agent::EVENT_RETENTION_SCHEDULES if self['keep_events_for'].present? && keep_events_for.requires_merge?
+      yield 'propagate_immediately', propagate_immediately, boolean if self['propagate_immediately'].present? && propagate_immediately.requires_merge?
+      yield 'disabled', disabled, boolean if disabled.requires_merge?
+    end
+
+    # Unfortunately Ruby 1.9's OpenStruct doesn't expose [] and []=.
+    unless instance_methods.include?(:[]=)
+      def [](key)
+        self.send(sanitize key)
+      end
+
+      def []=(key, val)
+        self.send("#{sanitize key}=", val)
+      end
+
+      def sanitize(key)
+        key.gsub(/[^a-zA-Z0-9_-]/, '')
+      end
+    end
+  end
+end

+ 4 - 0
app/models/scenario_membership.rb

@@ -0,0 +1,4 @@
+class ScenarioMembership < ActiveRecord::Base
+  belongs_to :agent, :inverse_of => :scenario_memberships
+  belongs_to :scenario, :inverse_of => :scenario_memberships
+end

+ 2 - 2
app/models/user.rb

@@ -26,11 +26,11 @@ class User < ActiveRecord::Base
   has_many :events, -> { order("events.created_at desc") }, :dependent => :delete_all, :inverse_of => :user
   has_many :agents, -> { order("agents.created_at desc") }, :dependent => :destroy, :inverse_of => :user
   has_many :logs, :through => :agents, :class_name => "AgentLog"
+  has_many :scenarios, :inverse_of => :user, :dependent => :destroy
   has_many :services, -> { order("services.name")}, :dependent => :destroy
-  
 
   def available_services
-    Service.where("user_id = ? or global = true", self.id).order("services.name desc") 
+    Service.where("user_id = ? or global = true", self.id).order("services.name desc")
   end
 
   # Allow users to login via either email or username.

+ 12 - 2
app/views/agents/_action_menu.html.erb

@@ -27,15 +27,25 @@
     <% end %>
   </li>
 
+  <% if agent.scenarios.length > 0 %>
+    <li class="divider"></li>
+
+    <% agent.scenarios.each do |scenario| %>
+      <li>
+        <%= link_to "<span class='color-warning glyphicon glyphicon-remove-circle'></span> Remove from <span class='scenario label label-info'>#{h scenario.name}</span>".html_safe, leave_scenario_agent_path(agent, :scenario_id => scenario.to_param, :return => returnTo), method: :put, :tabindex => "-1" %>
+      </li>
+    <% end %>
+  <% end %>
+
   <li class="divider"></li>
 
   <% if agent.can_create_events? && agent.events.count > 0 %>
     <li>
-      <%= link_to '<span class="color-danger glyphicon glyphicon-trash"></span> Delete all events'.html_safe, remove_events_agent_path(agent), method: :delete, data: {confirm: 'Are you sure you want to delete ALL events for this Agent?'}, :tabindex => "-1" %>
+      <%= link_to '<span class="color-danger glyphicon glyphicon-trash"></span> Delete all events'.html_safe, remove_events_agent_path(agent, :return => returnTo), method: :delete, data: {confirm: 'Are you sure you want to delete ALL emitted events for this Agent?'}, :tabindex => "-1" %>
     </li>
   <% end %>
 
   <li>
-    <%= link_to '<span class="color-danger glyphicon glyphicon-remove"></span> Delete agent'.html_safe, agent_path(agent), method: :delete, data: { confirm: 'Are you sure?' }, :tabindex => "-1" %>
+    <%= link_to '<span class="color-danger glyphicon glyphicon-remove"></span> Delete agent'.html_safe, agent_path(agent, :return => returnTo), method: :delete, data: { confirm: 'Are you sure that you want to permanently delete this Agent?' }, :tabindex => "-1" %>
   </li>
 </ul>

+ 14 - 2
app/views/agents/_form.html.erb

@@ -50,6 +50,7 @@
           <div class='event-related-region' data-can-create-events="<%= @agent.can_create_events? %>">
             <div class="form-group">
               <%= f.label :keep_events_for, "Keep events" %>
+              <span class="glyphicon glyphicon-question-sign hover-help" data-content="In order to conserve disk space, you can choose to have events created by this Agent expire after a certain period of time.  Make sure you keep them long enough to allow any subsequent Agents to make use of them."></span>
               <%= f.select :keep_events_for, options_for_select(Agent::EVENT_RETENTION_SCHEDULES, @agent.keep_events_for), {}, :class => 'form-control' %>
             </div>
           </div>
@@ -68,13 +69,24 @@
               <% end %>
             </div>
           </div>
+
+          <% if current_user.scenario_count > 0 %>
+            <div class="form-group">
+              <%= f.label :scenarios %>
+              <span class="glyphicon glyphicon-question-sign hover-help" data-content="Use Scenarios to group sets of Agents, both for organization, and to make them easy to export and share."></span>
+              <%= f.select(:scenario_ids,
+                           options_for_select(current_user.scenarios.pluck(:name, :id), @agent.scenario_ids),
+                           {}, { :multiple => true, :size => 5, :class => 'select2 form-control' }) %>
+            </div>
+          <% end %>
+
         </div>
 
         <!-- Form controls full width -->
         <div class="col-md-12">
           <div class="form-group">
             <%= f.label :options %>
-            <textarea rows="10" id="agent_options" name="agent[options]" class="form-control live-json-editor <%= (@agent.new_record? && @agent.options == {}) ? "showing-default" : "" %>">
+            <textarea rows="15" id="agent_options" name="agent[options]" class="form-control live-json-editor <%= (@agent.new_record? && @agent.options == {}) ? "showing-default" : "" %>">
               <%= Utils.jsonify((@agent.new_record? && @agent.options == {}) ? @agent.default_options : @agent.options) %>
             </textarea>
           </div>
@@ -101,7 +113,7 @@
 
   <div class='row'>
     <div class="col-md-12">
-      <%= f.submit :class => "btn btn-primary" %>
+      <%= f.submit "Save", :class => "btn btn-primary" %>
     </div>
   </div>
       

+ 75 - 0
app/views/agents/_table.html.erb

@@ -0,0 +1,75 @@
+<div class='table-responsive'>
+  <table class='table table-striped'>
+    <tr>
+      <th>Name</th>
+      <th>Schedule</th>
+      <th>Last Check</th>
+      <th>Last Event Out</th>
+      <th>Last Event In</th>
+      <th>Events Created</th>
+      <th>Working?</th>
+      <th></th>
+    </tr>
+
+    <% @agents.each do |agent| %>
+      <tr>
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
+          <%= link_to agent.name, agent_path(agent) %>
+          <br/>
+          <span class='text-muted'><%= agent.short_type.titleize %></span>
+          <% if agent.scenarios.present? %>
+            <span>
+              <%= scenario_links(agent) %>
+            </span>
+          <% end %>
+        </td>
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
+          <% if agent.can_be_scheduled? %>
+            <%= agent.schedule.to_s.humanize.titleize %>
+          <% else %>
+            <span class='not-applicable'></span>
+          <% end %>
+        </td>
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
+          <% if agent.can_be_scheduled? %>
+            <%= agent.last_check_at ? time_ago_in_words(agent.last_check_at) + " ago" : "never" %>
+          <% else %>
+            <span class='not-applicable'></span>
+          <% end %>
+        </td>
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
+          <% if agent.can_create_events? %>
+            <%= agent.last_event_at ? time_ago_in_words(agent.last_event_at) + " ago" : "never" %>
+          <% else %>
+            <span class='not-applicable'></span>
+          <% end %>
+        </td>
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
+          <% if agent.can_receive_events? %>
+            <%= agent.last_receive_at ? time_ago_in_words(agent.last_receive_at) + " ago" : "never" %>
+          <% else %>
+            <span class='not-applicable'></span>
+          <% end %>
+        </td>
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
+          <% if agent.can_create_events? %>
+            <%= link_to(agent.events_count || 0, events_path(:agent => agent.to_param)) %>
+          <% else %>
+            <span class='not-applicable'></span>
+          <% end %>
+        </td>
+        <td><%= working(agent) %></td>
+        <td>
+          <div class="btn-group">
+            <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
+              <span class="glyphicon glyphicon-th-list"></span> Actions <span class="caret"></span>
+            </button>
+            <%= render 'agents/action_menu', :agent => agent, :returnTo => (defined?(returnTo) && returnTo) || "index" %>
+          </div>
+        </td>
+      </tr>
+    <% end %>
+  </table>
+</div>
+
+<%= paginate @agents, :theme => 'twitter-bootstrap-3' %>

+ 1 - 1
app/views/agents/agent_views/manual_event_agent/_show.html.erb

@@ -14,7 +14,7 @@
 
 <script>
   $(function () {
-    var payloadJsonEditor = window.setupJsonEditor($(".payload-editor"));
+    var payloadJsonEditor = window.setupJsonEditor($(".payload-editor"))[0];
     $("#create-event-form").submit(function (e) {
       e.preventDefault();
       var $form = $("#create-event-form");

+ 1 - 1
app/views/agents/diagram.html.erb

@@ -5,7 +5,7 @@
         <h2>Agent Event Flow</h2>
       </div>
       <div class="btn-group">
-        <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, agents_path, class: "btn btn-default" %>
+        <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, (params[:scenario_id] ? scenario_path(params[:scenario_id]) : agents_path), class: "btn btn-default" %>
       </div>
 
       <div class='digraph'>

+ 1 - 70
app/views/agents/index.html.erb

@@ -5,76 +5,7 @@
         <h2>Your Agents</h2>
       </div>
 
-      <div class='table-responsive'>
-        <table class='table table-striped'>
-          <tr>
-            <th>Name</th>
-            <th>Schedule</th>
-            <th>Last Check</th>
-            <th>Last Event Out</th>
-            <th>Last Event In</th>
-            <th>Events Created</th>
-            <th>Working?</th>
-            <th></th>
-          </tr>
-
-          <% @agents.each do |agent| %>
-            <tr>
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
-                <%= link_to agent.name, agent_path(agent) %>
-                <br/>
-                <span class='text-muted'><%= agent.short_type.titleize %></span>
-              </td>
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
-                <% if agent.can_be_scheduled? %>
-                  <%= agent.schedule.to_s.humanize.titleize %>
-                <% else %>
-                  <span class='not-applicable'></span>
-                <% end %>
-              </td>
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
-                <% if agent.can_be_scheduled? %>
-                  <%= agent.last_check_at ? time_ago_in_words(agent.last_check_at) + " ago" : "never" %>
-                <% else %>
-                  <span class='not-applicable'></span>
-                <% end %>
-              </td>
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
-                <% if agent.can_create_events? %>
-                  <%= agent.last_event_at ? time_ago_in_words(agent.last_event_at) + " ago" : "never" %>
-                <% else %>
-                  <span class='not-applicable'></span>
-                <% end %>
-              </td>
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
-                <% if agent.can_receive_events? %>
-                  <%= agent.last_receive_at ? time_ago_in_words(agent.last_receive_at) + " ago" : "never" %>
-                <% else %>
-                  <span class='not-applicable'></span>
-                <% end %>
-              </td>
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
-                <% if agent.can_create_events? %>
-                  <%= link_to(agent.events_count || 0, events_path(:agent => agent.to_param)) %>
-                <% else %>
-                  <span class='not-applicable'></span>
-                <% end %>
-              </td>
-              <td><%= working(agent) %></td>
-              <td>
-                <div class="btn-group">
-                  <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
-                    <span class="glyphicon glyphicon-th-list"></span> Actions <span class="caret"></span>
-                  </button>
-                  <%= render 'action_menu', :agent => agent, :returnTo => "index" %>
-                </div>
-              </td>
-            </tr>
-          <% end %>
-        </table>
-      </div>
-
-      <%= paginate @agents, :theme => 'twitter-bootstrap-3' %>
+      <%= render 'agents/table' %>
 
       <br/>
 

+ 1 - 1
app/views/agents/show.html.erb

@@ -22,7 +22,7 @@
 
           <li class="dropdown">
             <a class="dropdown-toggle" data-toggle="dropdown" href="#"><span class="glyphicon glyphicon-th-list"></span> Actions <span class="caret"></span></a>
-            <%= render 'action_menu', :agent => @agent, :returnTo => "show" %>
+            <%= render 'agents/action_menu', :agent => @agent, :returnTo => "show" %>
           </li>
         </ul>
       </div>

+ 2 - 2
app/views/events/index.html.erb

@@ -20,13 +20,13 @@
           <% next unless event.agent %>
           <tr>
             <td><%= link_to event.agent.name, agent_path(event.agent) %></td>
-            <td><%= time_ago_in_words event.created_at %> ago</td>
+            <td title='<%= event.created_at %>'><%= time_ago_in_words event.created_at %> ago</td>
             <td class='payload'><%= truncate event.payload.to_json, :length => 90, :omission => "" %></td>
             <td>
               <div class="btn-group btn-group-xs">
                 <%= link_to 'Show', event_path(event), class: "btn btn-default" %>
                 <%= link_to 'Re-emit', reemit_event_path(event), method: :post, data: { confirm: 'Are you sure you want to duplicate this event and emit the new one now?' }, class: "btn btn-default" %>
-                <%= link_to 'Delete', event_path(event), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default btn-danger" %>
+                <%= link_to 'Delete', event_path(event), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default" %>
               </div>
             </td>
           </tr>

+ 1 - 0
app/views/layouts/_navigation.html.erb

@@ -13,6 +13,7 @@
   <% if user_signed_in? %>
     <ul class='nav navbar-nav'>
       <%= nav_link "Agents", agents_path %>
+      <%= nav_link "Scenarios", scenarios_path %>
       <%= nav_link "Events", events_path %>
       <%= nav_link "Credentials", user_credentials_path %>
       <%= nav_link "Services", services_path %>

+ 14 - 10
app/views/layouts/application.html.erb

@@ -31,18 +31,22 @@
 
     <script>
       var agentPaths = {};
-      <% if current_user -%>
-        var myAgents = <%= Utils.jsonify(current_user.agents.select([:name, :id, :schedule]).inject({}) {|m, a| m[a.name] = agent_path(a); m }) %>;
+      var agentNames = [];
+      <% if current_user.present? -%>
+        var myAgents = <%= Utils.jsonify(current_user.agents.pluck(:name, :id).inject({}) {|m, a| m[a.first] = agent_path(a.last); m }) %>;
+        var myScenarios = <%= Utils.jsonify(current_user.scenarios.pluck(:name, :id).inject({}) {|m, s| m[s.first + " Scenario"] = scenario_path(s.last); m }) %>;
         $.extend(agentPaths, myAgents);
+        $.extend(agentPaths, myScenarios);
+        agentPaths["All Agents Index"] = <%= Utils.jsonify agents_path %>;
+        agentPaths["New Agent"] = <%= Utils.jsonify new_agent_path %>;
+        agentPaths["Account"] = <%= Utils.jsonify edit_user_registration_path %>;
+        agentPaths["Events Index"] = <%= Utils.jsonify events_path %>;
+        agentPaths["View Agent Diagram"] = <%= Utils.jsonify diagram_agents_path %>;
+        agentPaths["Run Event Propagation"] = { url: <%= Utils.jsonify propagate_agents_path %>, method: 'POST' };
+
+
+        $.each(agentPaths, function(name, v) { agentNames.push(name); });
       <% end -%>
-      agentPaths["All Agents Index"] = <%= Utils.jsonify agents_path %>;
-      agentPaths["New Agent"] = <%= Utils.jsonify new_agent_path %>;
-      agentPaths["Account"] = <%= Utils.jsonify edit_user_registration_path %>;
-      agentPaths["Events Index"] = <%= Utils.jsonify events_path %>;
-      agentPaths["View Agent Diagram"] = <%= Utils.jsonify diagram_agents_path %>;
-      agentPaths["Run Event Propagation"] = { url: <%= Utils.jsonify propagate_agents_path %>, method: 'POST' };
-      var agentNames = [];
-      $.each(agentPaths, function(name, v) { agentNames.push(name); });
     </script>
   </body>
 </html>

+ 31 - 0
app/views/scenario_imports/_step_one.html.erb

@@ -0,0 +1,31 @@
+<div class="row">
+  <div class="page-header">
+    <h2>
+      Import a Public Scenario
+    </h2>
+  </div>
+</div>
+
+<div class='row'>
+  <blockquote>You can import Scenarios, either from a <code>.json</code> file, or via a public
+    Scenario URL. When you import a Scenario, Huginn will keep track of where it came from and
+    later let you update it.</blockquote>
+</div>
+
+<div class='row'>
+  <div class="col-md-4">
+    <div class="form-group">
+      <%= f.label :url, 'Option 1: Provide a Public Scenario URL' %>
+      <%= f.text_field :url, :class => 'form-control', :placeholder => "Public Scenario URL" %>
+    </div>
+
+    <div class="form-group">
+      <%= f.label :file, 'Option 2: Upload a Scenario JSON File' %>
+      <%= f.file_field :file, :class => 'form-control' %>
+    </div>
+
+    <div class='form-actions'>
+      <%= f.submit "Start Import", :class => "btn btn-primary" %>
+    </div>
+  </div>
+</div>

+ 154 - 0
app/views/scenario_imports/_step_two.html.erb

@@ -0,0 +1,154 @@
+<div class="row">
+  <div class="col-md-12">
+    <% if @scenario_import.dangerous? %>
+      <div class="alert alert-danger">
+        <span class='glyphicon glyphicon-warning-sign'></span>
+        This Scenario contains one or more potentially dangerous Agents.
+        These may be able to run local commands or execute code.
+        Please be sure that you understand the Agent configurations before importing!
+      </div>
+    <% end %>
+
+    <% if @scenario_import.existing_scenario.present? %>
+      <div class="alert alert-warning">
+        <span class='glyphicon glyphicon-warning-sign'></span>
+        This Scenario already exists in your system. The import will update your existing
+        <span class='label label-info scenario'><%= @scenario_import.existing_scenario.name %></span> Scenario's title
+        and
+        description. Below you can customize how the individual agents get updated.
+      </div>
+    <% end %>
+
+    <div class="page-header">
+      <h2>
+        <%= @scenario_import.parsed_data["name"] %>
+        <span class='text-muted'>
+          (<%= pluralize @scenario_import.parsed_data["agents"].length, "Agent" %>;
+          exported <%= time_ago_in_words Time.parse(@scenario_import.parsed_data["exported_at"]) %> ago)
+        </span>
+      </h2>
+    </div>
+
+    <% if @scenario_import.parsed_data["description"].present? %>
+      <blockquote><%= @scenario_import.parsed_data["description"] %></blockquote>
+    <% end %>
+
+  </div>
+</div>
+
+<div class='agent-import-list'>
+  <% @scenario_import.agent_diffs.each.with_index do |agent_diff, index| %>
+    <div class='agent-import' data-index='<%= index %>'>
+
+      <div class='row'>
+        <div class='col-md-12'>
+          <h3>
+            <a href='#' data-toggle="modal" data-target="#agent_options_<%= index %>"><%= agent_diff.name.incoming %></a>
+            <span class='text-muted'>
+              (<%= agent_diff.type.incoming %><% " -- WARNING: this Agent's type has been changed.  This import will likely fail!" if agent_diff.type.requires_merge? %>)
+            </span>
+          </h3>
+
+          <% if agent_diff.agent_exists? %>
+            <div class="instructions">
+              This Agent exists in your Huginn system.
+
+              <% if agent_diff.requires_merge? %>
+                Here are the differences between the incoming version and the one you have now. For each field, please
+                select which value you'd like to keep.
+              <% else %>
+                It's already up-to-date.
+              <% end %>
+            </div>
+          <% end %>
+        </div>
+      </div>
+
+      <div class="modal fade" id="agent_options_<%= index %>" tabindex="-1" role="dialog" aria-labelledby="modalLabel<%= index %>" aria-hidden="true">
+        <div class="modal-dialog modal-lg">
+          <div class="modal-content">
+            <div class="modal-header">
+              <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+              <h4 class="modal-title" id="modalLabel<%= index %>">Options for '<%= agent_diff.name.updated %>'</h4>
+            </div>
+            <div class="modal-body">
+              <pre class='options'><%= Utils.pretty_jsonify agent_diff.options.incoming %></pre>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <% agent_diff.each_field do |field, value, selection_options| %>
+        <div class='row'>
+          <div class='col-md-4'>
+            <div class="form-group">
+              <%= label_tag "scenario_import[merges][#{index}][#{field}]", field.titleize %>
+              <% if selection_options.present? %>
+                <div>
+                  Your current Agent's value is:
+                  <span class='current'><%= selection_options.find { |s| s.last.to_s == value.current.to_s }.first %></span>
+                </div>
+                <%= select_tag "scenario_import[merges][#{index}][#{field}]", options_for_select(selection_options, value.updated), :class => 'form-control' %>
+              <% else %>
+                <div>
+                  Your current Agent's value is: <span class='current'><%= value.current.to_s %></span>
+                </div>
+                <%= text_field_tag "scenario_import[merges][#{index}][#{field}]", value.updated, :class => 'form-control' %>
+              <% end %>
+            </div>
+          </div>
+        </div>
+      <% end %>
+
+      <div class='row'>
+        <% if agent_diff.options.requires_merge? %>
+          <div class='col-md-12'>
+            <label>Options</label>
+          </div>
+
+          <div class='col-md-6'>
+            <textarea name="scenario_import[merges][<%= index %>][options]" rows='15' class="form-control live-json-editor">
+              <%= Utils.pretty_jsonify(agent_diff.options.updated) %>
+            </textarea>
+          </div>
+
+          <div class='col-md-6'>
+            <div>
+              Your current options:
+            </div>
+            <pre class='options'><%= Utils.pretty_jsonify agent_diff.options.current %></pre>
+          </div>
+        <% end %>
+      </div>
+    </div>
+  <% end %>
+</div>
+
+<div class='row'>
+  <div class='col-md-12'>
+    <div class="checkbox">
+      <%= f.label :do_import do %>
+        <%= f.check_box :do_import %> I confirm that I want to import these Agents.
+      <% end %>
+    </div>
+
+    <div class='form-actions'>
+      <%= f.submit "Finish Import", :class => "btn btn-primary" %>
+    </div>
+  </div>
+</div>
+
+
+<script>
+//  $(function() {
+//    $('.agent-import-list .options-toggle').on('click', function (e) {
+//      e.preventDefault();
+//      $(this).siblings('.options').slideToggle()
+//      if ($(this).text() == "Show Options") {
+//        $(this).text("Hide Options");
+//      } else {
+//        $(this).text("Show Options");
+//      }
+//    });
+//  });
+</script>

+ 32 - 0
app/views/scenario_imports/new.html.erb

@@ -0,0 +1,32 @@
+<div class='container scenario-import'>
+  <div class="row">
+    <div class="col-md-12">
+      <% if @scenario_import.errors.any? %>
+        <div class="row well">
+          <h2><%= pluralize(@scenario_import.errors.count, "error") %> prohibited this Scenario from being imported:</h2>
+          <% @scenario_import.errors.full_messages.each do |msg| %>
+            <p class='text-warning'><%= msg %></p>
+          <% end %>
+        </div>
+      <% end %>
+    </div>
+  </div>
+
+  <%= form_for @scenario_import, :multipart => true do |f| %>
+    <%= f.hidden_field :data %>
+
+    <% if @scenario_import.step_one? %>
+      <%= render 'step_one', :f => f %>
+    <% elsif @scenario_import.step_two? %>
+      <%= render 'step_two', :f => f %>
+    <% end %>
+  <% end %>
+
+  <hr />
+
+  <div class="row">
+    <div class="col-md-12">
+      <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %>
+    </div>
+  </div>
+</div>

+ 57 - 0
app/views/scenarios/_form.html.erb

@@ -0,0 +1,57 @@
+<%= form_for(@scenario, :method => @scenario.new_record? ? "POST" : "PUT") do |f| %>
+  <% if @scenario.errors.any? %>
+    <div class="row well">
+      <h2><%= pluralize(@scenario.errors.count, "error") %> prohibited this Scenario from being saved:</h2>
+      <% @scenario.errors.full_messages.each do |msg| %>
+        <p class='text-warning'><%= msg %></p>
+      <% end %>
+    </div>
+  <% end %>
+
+  <div class="row">
+    <div class="col-md-4">
+      <div class="form-group">
+        <%= f.label :name %>
+        <%= f.text_field :name, :class => 'form-control', :placeholder => "Name your Scenario" %>
+      </div>
+    </div>
+  </div>
+
+  <div class="row">
+    <div class="col-md-8">
+      <div class="form-group">
+        <%= f.label :description, "Optional Description" %>
+        <%= f.text_area :description, :rows => 10, :class => 'form-control', :placeholder => "Optionally describe what this Scenario will do.  If this will be public, you should also include some contact information." %>
+      </div>
+
+      <div class="checkbox">
+        <%= f.label :public do %>
+          <%= f.check_box :public %> Share this Scenario publicly
+        <% end %>
+        <span class="glyphicon glyphicon-question-sign hover-help" data-content="When selected, this Scenario and all Agents in it will be made public.  An export URL will be available to share with other Huginn users.  Be very careful that you do not have secret credentials stored in these Agents' options.  Instead, use Credentials by reference."></span>
+      </div>
+
+    </div>
+  </div>
+
+  <div class="row">
+    <div class="col-md-4">
+      <div class="form-group">
+        <div>
+          <%= f.label :agents %>
+          <%= f.select(:agent_ids,
+                       options_for_select(current_user.agents.pluck(:name, :id), @scenario.agent_ids),
+                       {}, { :multiple => true, :size => 5, :class => 'select2 form-control' }) %>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="row">
+    <div class="col-md-12">
+      <div class='form-actions' style='clear: both'>
+        <%= f.submit "Save Scenario", :class => "btn btn-primary" %>
+      </div>
+    </div>
+  </div>
+<% end %>

+ 21 - 0
app/views/scenarios/edit.html.erb

@@ -0,0 +1,21 @@
+<div class='container'>
+  <div class='row'>
+    <div class='col-md-12'>
+      <div class="page-header">
+        <h2>
+          Edit Scenario
+        </h2>
+      </div>
+
+      <%= render 'form' %>
+
+      <hr>
+
+      <div class="row">
+        <div class="col-md-12">
+          <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>

+ 50 - 0
app/views/scenarios/index.html.erb

@@ -0,0 +1,50 @@
+<div class='container'>
+  <div class='row'>
+    <div class='col-md-12'>
+      <div class="page-header">
+        <h2>
+          Your Scenarios
+        </h2>
+      </div>
+
+      <blockquote>Scenarios are named groups of Agents.  Scenarios allow you to organize your agents,
+        and to import and export sets of Agents to share.</blockquote>
+
+      <table class='table table-striped'>
+        <tr>
+          <th>Name</th>
+          <th>Agents</th>
+          <th>Public</th>
+          <th></th>
+        </tr>
+
+        <% @scenarios.each do |scenario| %>
+          <tr>
+            <td>
+              <%= link_to(scenario.name, scenario) %>
+            </td>
+            <td><%= link_to pluralize(scenario.agents.count, "agent"), scenario %></td>
+            <td><%= scenario.public? ? "yes" : "no" %></td>
+            <td>
+              <div class="btn-group btn-group-xs" style="float: right">
+                <%= link_to 'Show', scenario, class: "btn btn-default" %>
+                <%= link_to 'Edit', edit_scenario_path(scenario), class: "btn btn-default" %>
+                <%= link_to 'Share', share_scenario_path(scenario), class: "btn btn-default" %>
+                <%= link_to 'Delete', scenario_path(scenario), method: :delete, data: { confirm: "This will remove the '#{scenario.name}' Scenerio from all Agents and delete it.  Are you sure?" }, class: "btn btn-default" %>
+              </div>
+            </td>
+          </tr>
+        <% end %>
+      </table>
+
+      <%= paginate @scenarios, :theme => 'twitter-bootstrap' %>
+
+      <br/>
+
+      <div class="btn-group">
+        <%= link_to '<span class="glyphicon glyphicon-plus"></span> New Scenario'.html_safe, new_scenario_path, class: "btn btn-default" %>
+        <%= link_to '<span class="glyphicon glyphicon-plus"></span> Import Scenario'.html_safe, new_scenario_imports_path, class: "btn btn-default" %>
+      </div>
+    </div>
+  </div>
+</div>

+ 21 - 0
app/views/scenarios/new.html.erb

@@ -0,0 +1,21 @@
+<div class='container'>
+  <div class='row'>
+    <div class='col-md-12'>
+      <div class="page-header">
+        <h2>
+          Create a new Scenario
+        </h2>
+      </div>
+
+      <%= render 'form' %>
+
+      <hr>
+
+      <div class="row">
+        <div class="col-md-12">
+          <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>

+ 33 - 0
app/views/scenarios/share.html.erb

@@ -0,0 +1,33 @@
+<div class='container'>
+  <div class='row'>
+    <div class='col-md-12'>
+      <div class="page-header">
+        <h2>Share <span class='label label-info scenario'><%= @scenario.name %></span> with the world</h2>
+      </div>
+
+      <p>
+        <strong>Please be sure that none of the Agents in this Scenario have sensitive data in their settings before sharing!</strong>
+      </p>
+
+      <% if @scenario.public? %>
+        <p>
+          This Scenario is public.  You can <%= link_to "download and share your export file", export_scenario_path(@scenario, :format => :json) %>, or give out this URL:
+        </p>
+
+        <form onsubmit='return false;'>
+          <input type='text' class='form-control' value='<%= export_scenario_url(@scenario, :format => :json) %>' onclick="return this.select();"/>
+        </form>
+      <% else %>
+        This Scenario is not public.  You can share it by <%= link_to "downloading and sharing your export file", export_scenario_path(@scenario, :format => :json) %>.
+      <% end %>
+
+      <hr>
+
+      <div class="row">
+        <div class="col-md-12">
+          <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenario_path(@scenario), class: "btn btn-default" %>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>

+ 28 - 0
app/views/scenarios/show.html.erb

@@ -0,0 +1,28 @@
+<div class='container'>
+  <div class='row'>
+    <div class='col-md-12'>
+      <div class="page-header">
+        <h2><span class='label label-info scenario'><%= @scenario.name %></span> <%= "Public" if @scenario.public? %> Scenario</h2>
+      </div>
+
+      <% if @scenario.description.present? %>
+        <blockquote><%= @scenario.description %></blockquote>
+      <% end %>
+
+      <%= render 'agents/table', :returnTo => scenario_path(@scenario) %>
+
+      <br/>
+
+      <div class="btn-group">
+        <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %>
+        <%= link_to '<span class="glyphicon glyphicon-random"></span> View Diagram'.html_safe, diagram_agents_path(:scenario_id => @scenario.to_param), class: "btn btn-default" %>
+        <%= link_to '<span class="glyphicon glyphicon-edit"></span> Edit'.html_safe, edit_scenario_path(@scenario), class: "btn btn-default" %>
+        <% if @scenario.source_url.present? %>
+          <%= link_to '<span class="glyphicon glyphicon-plus"></span> Update'.html_safe, new_scenario_imports_path(:url => @scenario.source_url), class: "btn btn-default" %>
+        <% end %>
+        <%= link_to '<span class="glyphicon glyphicon-share-alt"></span> Share'.html_safe, share_scenario_path(@scenario), class: "btn btn-default" %>
+        <%= link_to '<span class="glyphicon glyphicon-trash"></span> Delete'.html_safe, scenario_path(@scenario), method: :delete, data: { confirm: "This will remove the '#{@scenario.name}' Scenerio from all Agents and delete it.  Are you sure?" }, class: "btn btn-default" %>
+      </div>
+    </div>
+  </div>
+</div>

+ 12 - 0
config/routes.rb

@@ -3,6 +3,7 @@ Huginn::Application.routes.draw do
     member do
       post :run
       post :handle_details_post
+      put :leave_scenario
       delete :remove_events
     end
 
@@ -26,6 +27,17 @@ Huginn::Application.routes.draw do
     end
   end
 
+  resources :scenarios do
+    collection do
+      resource :scenario_imports, :only => [:new, :create]
+    end
+
+    member do
+      get :share
+      get :export
+    end
+  end
+
   resources :user_credentials, :except => :show
 
   resources :services, :only => [:index, :destroy] do

+ 12 - 0
db/migrate/20140509170420_create_scenarios.rb

@@ -0,0 +1,12 @@
+class CreateScenarios < ActiveRecord::Migration
+  def change
+    create_table :scenarios do |t|
+      t.string :name, :null => false
+      t.integer :user_id, :null => false
+
+      t.timestamps
+    end
+
+    add_column :users, :scenario_count, :integer, :null => false, :default => 0
+  end
+end

+ 10 - 0
db/migrate/20140509170443_create_scenario_memberships.rb

@@ -0,0 +1,10 @@
+class CreateScenarioMemberships < ActiveRecord::Migration
+  def change
+    create_table :scenario_memberships do |t|
+      t.integer :agent_id, :null => false
+      t.integer :scenario_id, :null => false
+
+      t.timestamps
+    end
+  end
+end

+ 8 - 0
db/migrate/20140531232016_add_fields_to_scenarios.rb

@@ -0,0 +1,8 @@
+class AddFieldsToScenarios < ActiveRecord::Migration
+  def change
+    add_column :scenarios, :description, :text
+    add_column :scenarios, :public, :boolean, :default => false, :null => false
+    add_column :scenarios, :guid, :string, :null => false
+    add_column :scenarios, :source_url, :string
+  end
+end

+ 7 - 0
db/migrate/20140602014917_add_indices_to_scenarios.rb

@@ -0,0 +1,7 @@
+class AddIndicesToScenarios < ActiveRecord::Migration
+  def change
+    add_index :scenarios, [:user_id, :guid], :unique => true
+    add_index :scenario_memberships, :agent_id
+    add_index :scenario_memberships, :scenario_id
+  end
+end

+ 15 - 0
db/migrate/20140605032822_add_guid_to_agents.rb

@@ -0,0 +1,15 @@
+class AddGuidToAgents < ActiveRecord::Migration
+  class Agent < ActiveRecord::Base; end
+
+  def change
+    add_column :agents, :guid, :string
+
+    Agent.find_each do |agent|
+      agent.update_attribute :guid, SecureRandom.hex
+    end
+
+    change_column_null :agents, :guid, false
+
+    add_index :agents, :guid
+  end
+end

+ 27 - 1
db/schema.rb

@@ -11,7 +11,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 20140525150140) do
+ActiveRecord::Schema.define(version: 20140605032822) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -46,8 +46,10 @@ ActiveRecord::Schema.define(version: 20140525150140) do
     t.boolean  "propagate_immediately", default: false, null: false
     t.boolean  "disabled",              default: false, null: false
     t.integer  "service_id"
+    t.string   "guid",                                  null: false
   end
 
+  add_index "agents", ["guid"], name: "index_agents_on_guid", using: :btree
   add_index "agents", ["schedule"], name: "index_agents_on_schedule", using: :btree
   add_index "agents", ["type"], name: "index_agents_on_type", using: :btree
   add_index "agents", ["user_id", "created_at"], name: "index_agents_on_user_id_and_created_at", using: :btree
@@ -94,6 +96,29 @@ ActiveRecord::Schema.define(version: 20140525150140) do
   add_index "links", ["receiver_id", "source_id"], name: "index_links_on_receiver_id_and_source_id", using: :btree
   add_index "links", ["source_id", "receiver_id"], name: "index_links_on_source_id_and_receiver_id", using: :btree
 
+  create_table "scenario_memberships", force: true do |t|
+    t.integer  "agent_id",    null: false
+    t.integer  "scenario_id", null: false
+    t.datetime "created_at"
+    t.datetime "updated_at"
+  end
+
+  add_index "scenario_memberships", ["agent_id"], name: "index_scenario_memberships_on_agent_id", using: :btree
+  add_index "scenario_memberships", ["scenario_id"], name: "index_scenario_memberships_on_scenario_id", using: :btree
+
+  create_table "scenarios", force: true do |t|
+    t.string   "name",                        null: false
+    t.integer  "user_id",                     null: false
+    t.datetime "created_at"
+    t.datetime "updated_at"
+    t.text     "description"
+    t.boolean  "public",      default: false, null: false
+    t.string   "guid",                        null: false
+    t.string   "source_url"
+  end
+
+  add_index "scenarios", ["user_id", "guid"], name: "index_scenarios_on_user_id_and_guid", unique: true, using: :btree
+
   create_table "services", force: true do |t|
     t.integer  "user_id"
     t.string   "provider"
@@ -141,6 +166,7 @@ ActiveRecord::Schema.define(version: 20140525150140) do
     t.datetime "locked_at"
     t.string   "username",                               null: false
     t.string   "invitation_code",                        null: false
+    t.integer  "scenario_count",         default: 0,     null: false
   end
 
   add_index "users", ["email"], name: "index_users_on_email", unique: true, using: :btree

+ 54 - 0
lib/agents_exporter.rb

@@ -0,0 +1,54 @@
+class AgentsExporter
+  attr_accessor :options
+
+  def initialize(options)
+    self.options = options
+  end
+
+  # Filename should have no commas or special characters to support Content-Disposition on older browsers.
+  def filename
+    ((options[:name] || '').downcase.gsub(/[^a-z0-9_-]/, '-').gsub(/-+/, '-').gsub(/^-|-$/, '').presence || 'exported-agents') + ".json"
+  end
+
+  def as_json(opts = {})
+    {
+      :name => options[:name].presence || 'No name provided',
+      :description => options[:description].presence || 'No description provided',
+      :source_url => options[:source_url],
+      :guid => options[:guid],
+      :exported_at => Time.now.utc.iso8601,
+      :agents => agents.map { |agent| agent_as_json(agent) },
+      :links => links
+    }
+  end
+
+  def agents
+    options[:agents].to_a
+  end
+
+  def links
+    agent_ids = agents.map(&:id)
+
+    contained_links = agents.map.with_index do |agent, index|
+      agent.links_as_source.where(:receiver_id => agent_ids).map do |link|
+        { :source => index, :receiver => agent_ids.index(link.receiver_id) }
+      end
+    end
+
+    contained_links.flatten.compact
+  end
+
+  def agent_as_json(agent)
+    {
+      :type => agent.type,
+      :name => agent.name,
+      :disabled => agent.disabled,
+      :guid => agent.guid,
+      :options => agent.options
+    }.tap do |options|
+      options[:schedule] = agent.schedule if agent.can_be_scheduled?
+      options[:keep_events_for] = agent.keep_events_for if agent.can_create_events?
+      options[:propagate_immediately] = agent.propagate_immediately if agent.can_receive_events?
+    end
+  end
+end

+ 0 - 0
spec/lib/inheritance_tracking_spec.rb → spec/concerns/inheritance_tracking_spec.rb


+ 103 - 0
spec/controllers/agents_controller_spec.rb

@@ -34,6 +34,47 @@ describe AgentsController do
     end
   end
 
+  describe "POST run" do
+    it "triggers Agent.async_check with the Agent's ID" do
+      sign_in users(:bob)
+      mock(Agent).async_check(agents(:bob_manual_event_agent).id)
+      post :run, :id => agents(:bob_manual_event_agent).to_param
+    end
+
+    it "can only be accessed by the Agent's owner" do
+      sign_in users(:jane)
+      lambda {
+        post :run, :id => agents(:bob_manual_event_agent).to_param
+      }.should raise_error(ActiveRecord::RecordNotFound)
+    end
+  end
+
+  describe "POST remove_events" do
+    it "deletes all events created by the given Agent" do
+      sign_in users(:bob)
+      agent_event = events(:bob_website_agent_event).id
+      other_event = events(:jane_website_agent_event).id
+      post :remove_events, :id => agents(:bob_website_agent).to_param
+      Event.where(:id => agent_event).count.should == 0
+      Event.where(:id => other_event).count.should == 1
+    end
+
+    it "can only be accessed by the Agent's owner" do
+      sign_in users(:jane)
+      lambda {
+        post :remove_events, :id => agents(:bob_website_agent).to_param
+      }.should raise_error(ActiveRecord::RecordNotFound)
+    end
+  end
+
+  describe "POST propagate" do
+    it "runs event propagation for all Agents" do
+      sign_in users(:bob)
+      mock.proxy(Agent).receive!
+      post :propagate
+    end
+  end
+
   describe "GET show" do
     it "only shows Agents for the current user" do
       sign_in users(:bob)
@@ -152,18 +193,80 @@ describe AgentsController do
       }.should raise_error(ActiveRecord::RecordNotFound)
     end
 
+    it "accepts JSON requests" do
+      sign_in users(:bob)
+      post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :format => :json
+      agents(:bob_website_agent).reload.name.should == "New name"
+      JSON.parse(response.body)['name'].should == "New name"
+      response.should be_success
+    end
+
     it "will not accept Agent sources owned by other users" do
       sign_in users(:bob)
       post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:source_ids => [agents(:jane_weather_agent).id])
       assigns(:agent).should have(1).errors_on(:sources)
     end
 
+    it "will not accept Scenarios owned by other users" do
+      sign_in users(:bob)
+      post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:scenario_ids => [scenarios(:jane_weather).id])
+      assigns(:agent).should have(1).errors_on(:scenarios)
+    end
+
     it "shows errors" do
       sign_in users(:bob)
       post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "")
       assigns(:agent).should have(1).errors_on(:name)
       response.should render_template("edit")
     end
+
+    describe "redirecting back" do
+      before do
+        sign_in users(:bob)
+      end
+
+      it "can redirect back to the show path" do
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :return => "show"
+        response.should redirect_to(agent_path(agents(:bob_website_agent)))
+      end
+
+      it "redirect back to the index path by default" do
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name")
+        response.should redirect_to(agents_path)
+      end
+
+      it "accepts return paths to scenarios" do
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :return => "/scenarios/2"
+        response.should redirect_to("/scenarios/2")
+      end
+
+      it "sanitizes return paths" do
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :return => "/scenar"
+        response.should redirect_to(agents_path)
+
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :return => "http://google.com"
+        response.should redirect_to(agents_path)
+
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :return => "javascript:alert(1)"
+        response.should redirect_to(agents_path)
+      end
+    end
+  end
+
+  describe "PUT leave_scenario" do
+    it "removes an Agent from the given Scenario for the current user" do
+      sign_in users(:bob)
+
+      agents(:bob_weather_agent).scenarios.should include(scenarios(:bob_weather))
+      put :leave_scenario, :id => agents(:bob_weather_agent).to_param, :scenario_id => scenarios(:bob_weather).to_param
+      agents(:bob_weather_agent).scenarios.should_not include(scenarios(:bob_weather))
+
+      Scenario.where(:id => scenarios(:bob_weather).id).should exist
+
+      lambda {
+        put :leave_scenario, :id => agents(:jane_weather_agent).to_param, :scenario_id => scenarios(:jane_weather).to_param
+      }.should raise_error(ActiveRecord::RecordNotFound)
+    end
   end
 
   describe "DELETE destroy" do

+ 26 - 0
spec/controllers/scenario_imports_controller_spec.rb

@@ -0,0 +1,26 @@
+require 'spec_helper'
+
+describe ScenarioImportsController do
+  before do
+    sign_in users(:bob)
+  end
+
+  describe "GET new" do
+    it "initializes a new ScenarioImport and renders new" do
+      get :new
+      assigns(:scenario_import).should be_a(ScenarioImport)
+      response.should render_template(:new)
+    end
+  end
+
+  describe "POST create" do
+    it "initializes a ScenarioImport for current_user, passing in params" do
+      post :create, :scenario_import => { :url => "bad url" }
+      assigns(:scenario_import).user.should == users(:bob)
+      assigns(:scenario_import).url.should == "bad url"
+      assigns(:scenario_import).should_not be_valid
+      response.should render_template(:new)
+    end
+  end
+end
+

+ 152 - 0
spec/controllers/scenarios_controller_spec.rb

@@ -0,0 +1,152 @@
+require 'spec_helper'
+
+describe ScenariosController do
+  def valid_attributes(options = {})
+    { :name => "some_name" }.merge(options)
+  end
+
+  before do
+    sign_in users(:bob)
+  end
+
+  describe "GET index" do
+    it "only returns Scenarios for the current user" do
+      get :index
+      assigns(:scenarios).all? {|i| i.user.should == users(:bob) }.should be_true
+    end
+  end
+
+  describe "GET show" do
+    it "only shows Scenarios for the current user" do
+      get :show, :id => scenarios(:bob_weather).to_param
+      assigns(:scenario).should eq(scenarios(:bob_weather))
+
+      lambda {
+        get :show, :id => scenarios(:jane_weather).to_param
+      }.should raise_error(ActiveRecord::RecordNotFound)
+    end
+
+    it "loads Agents for the requested Scenario" do
+      get :show, :id => scenarios(:bob_weather).to_param
+      assigns(:agents).pluck(:id).should eq(scenarios(:bob_weather).agents.pluck(:id))
+    end
+  end
+
+  describe "GET share" do
+    it "only displays Scenario share information for the current user" do
+      get :share, :id => scenarios(:bob_weather).to_param
+      assigns(:scenario).should eq(scenarios(:bob_weather))
+
+      lambda {
+        get :share, :id => scenarios(:jane_weather).to_param
+      }.should raise_error(ActiveRecord::RecordNotFound)
+    end
+  end
+
+  describe "GET export" do
+    it "returns a JSON file download from an instantiated AgentsExporter" do
+      get :export, :id => scenarios(:bob_weather).to_param
+      assigns(:exporter).options[:name].should == scenarios(:bob_weather).name
+      assigns(:exporter).options[:description].should == scenarios(:bob_weather).description
+      assigns(:exporter).options[:agents].should == scenarios(:bob_weather).agents
+      assigns(:exporter).options[:guid].should == scenarios(:bob_weather).guid
+      assigns(:exporter).options[:source_url].should be_false
+      response.headers['Content-Disposition'].should == 'attachment; filename="bob-s-weather-alert-scenario.json"'
+      response.headers['Content-Type'].should == 'application/json; charset=utf-8'
+      JSON.parse(response.body)["name"].should == scenarios(:bob_weather).name
+    end
+
+    it "only exports private Scenarios for the current user" do
+      get :export, :id => scenarios(:bob_weather).to_param
+      assigns(:scenario).should eq(scenarios(:bob_weather))
+
+      lambda {
+        get :export, :id => scenarios(:jane_weather).to_param
+      }.should raise_error(ActiveRecord::RecordNotFound)
+    end
+
+    describe "public exports" do
+      before do
+        scenarios(:jane_weather).update_attribute :public, true
+      end
+
+      it "exports public scenarios for other users when logged in" do
+        get :export, :id => scenarios(:jane_weather).to_param
+        assigns(:scenario).should eq(scenarios(:jane_weather))
+        assigns(:exporter).options[:source_url].should == export_scenario_url(scenarios(:jane_weather))
+      end
+
+      it "exports public scenarios for other users when logged out" do
+        sign_out :user
+        get :export, :id => scenarios(:jane_weather).to_param
+        assigns(:scenario).should eq(scenarios(:jane_weather))
+        assigns(:exporter).options[:source_url].should == export_scenario_url(scenarios(:jane_weather))
+      end
+    end
+  end
+
+  describe "GET edit" do
+    it "only shows Scenarios for the current user" do
+      get :edit, :id => scenarios(:bob_weather).to_param
+      assigns(:scenario).should eq(scenarios(:bob_weather))
+
+      lambda {
+        get :edit, :id => scenarios(:jane_weather).to_param
+      }.should raise_error(ActiveRecord::RecordNotFound)
+    end
+  end
+
+  describe "POST create" do
+    it "creates Scenarios for the current user" do
+      expect {
+        post :create, :scenario => valid_attributes
+      }.to change { users(:bob).scenarios.count }.by(1)
+    end
+
+    it "shows errors" do
+      expect {
+        post :create, :scenario => valid_attributes(:name => "")
+      }.not_to change { users(:bob).scenarios.count }
+      assigns(:scenario).should have(1).errors_on(:name)
+      response.should render_template("new")
+    end
+
+    it "will not create Scenarios for other users" do
+      expect {
+        post :create, :scenario => valid_attributes(:user_id => users(:jane).id)
+      }.to raise_error(ActiveModel::MassAssignmentSecurity::Error)
+    end
+  end
+
+  describe "PUT update" do
+    it "updates attributes on Scenarios for the current user" do
+      post :update, :id => scenarios(:bob_weather).to_param, :scenario => { :name => "new_name", :public => "1" }
+      response.should redirect_to(scenario_path(scenarios(:bob_weather)))
+      scenarios(:bob_weather).reload.name.should == "new_name"
+      scenarios(:bob_weather).should be_public
+
+      lambda {
+        post :update, :id => scenarios(:jane_weather).to_param, :scenario => { :name => "new_name" }
+      }.should raise_error(ActiveRecord::RecordNotFound)
+      scenarios(:jane_weather).reload.name.should_not == "new_name"
+    end
+
+    it "shows errors" do
+      post :update, :id => scenarios(:bob_weather).to_param, :scenario => { :name => "" }
+      assigns(:scenario).should have(1).errors_on(:name)
+      response.should render_template("edit")
+    end
+  end
+
+  describe "DELETE destroy" do
+    it "destroys only Scenarios owned by the current user" do
+      expect {
+        delete :destroy, :id => scenarios(:bob_weather).to_param
+      }.to change(Scenario, :count).by(-1)
+
+      lambda {
+        delete :destroy, :id => scenarios(:jane_weather).to_param
+      }.should raise_error(ActiveRecord::RecordNotFound)
+    end
+  end
+end

+ 10 - 1
spec/fixtures/agents.yml

@@ -4,6 +4,7 @@ jane_website_agent:
   events_count: 1
   schedule: "5pm"
   name: "ZKCD"
+  guid: <%= SecureRandom.hex %>
   options: <%= {
                  :url => "http://trailers.apple.com/trailers/home/rss/newtrailers.rss",
                  :expected_update_period_in_days => 2,
@@ -20,6 +21,7 @@ bob_website_agent:
   events_count: 1
   schedule: "midnight"
   name: "ZKCD"
+  guid: <%= SecureRandom.hex %>
   options: <%= {
                  :url => "http://xkcd.com",
                  :expected_update_period_in_days => 2,
@@ -35,6 +37,7 @@ bob_weather_agent:
   user: bob
   schedule: "midnight"
   name: "SF Weather"
+  guid: <%= SecureRandom.hex %>
   keep_events_for: 45
   options: <%= {
                  :location => 94102,
@@ -48,6 +51,7 @@ jane_weather_agent:
   user: jane
   schedule: "midnight"
   name: "SF Weather"
+  guid: <%= SecureRandom.hex %>
   keep_events_for: 30
   options: <%= {
                  :location => 94103,
@@ -60,6 +64,7 @@ jane_rain_notifier_agent:
   type: Agents::TriggerAgent
   user: jane
   name: "Jane's Rain Watcher"
+  guid: <%= SecureRandom.hex %>
   options: <%= {
                  :expected_receive_period_in_days => "2",
                  :rules => [{
@@ -74,6 +79,7 @@ bob_rain_notifier_agent:
   type: Agents::TriggerAgent
   user: bob
   name: "Bob's Rain Watcher"
+  guid: <%= SecureRandom.hex %>
   options: <%= {
                  :expected_receive_period_in_days => "2",
                  :rules => [{
@@ -88,6 +94,7 @@ bob_twitter_user_agent:
   type: Agents::TwitterUserAgent
   user: bob
   name: "Bob's Twitter User Watcher"
+  guid: <%= SecureRandom.hex %>
   options: <%= {
       :username => "tectonic",
       :expected_update_period_in_days => "2",
@@ -101,8 +108,10 @@ bob_manual_event_agent:
   type: Agents::ManualEventAgent
   user: bob
   name: "Bob's event testing agent"
+  guid: <%= SecureRandom.hex %>
 
 bob_basecamp_agent:
   type: Agents::BasecampAgent
   user: bob
-  service: generic
+  service: generic
+  guid: <%= SecureRandom.hex %>

+ 15 - 0
spec/fixtures/scenario_memberships.yml

@@ -0,0 +1,15 @@
+jane_weather_agent_scenario_membership:
+  agent: jane_weather_agent
+  scenario: jane_weather
+
+jane_rain_notifier_agent_scenario_membership:
+  agent: jane_rain_notifier_agent
+  scenario: jane_weather
+
+bob_weather_agent_scenario_membership:
+  agent: bob_weather_agent
+  scenario: bob_weather
+
+bob_rain_notifier_agent_scenario_membership:
+  agent: bob_rain_notifier_agent
+  scenario: bob_weather

+ 13 - 0
spec/fixtures/scenarios.yml

@@ -0,0 +1,13 @@
+jane_weather:
+  name: Jane's weather alert Scenario
+  user: jane
+  description: Jane's weather alert system
+  public: false
+  guid: random-guid-generated-by-bob
+
+bob_weather:
+  name: Bob's weather alert Scenario
+  user: bob
+  description: Bob's weather alert system
+  public: false
+  guid: random-guid-generated-by-jane

+ 3 - 1
spec/fixtures/users.yml

@@ -4,8 +4,10 @@ bob:
   email: "bob@example.com"
   username: bob
   invitation_code: <%= User::INVITATION_CODES.last %>
+  scenario_count: 1
 
 jane:
   email: "jane@example.com"
   username: jane
-  invitation_code: <%= User::INVITATION_CODES.last %>
+  invitation_code: <%= User::INVITATION_CODES.last %>
+  scenario_count: 1

+ 61 - 0
spec/lib/agents_exporter_spec.rb

@@ -0,0 +1,61 @@
+# encoding: utf-8
+
+require 'spec_helper'
+
+describe AgentsExporter do
+  describe "#as_json" do
+    let(:name) { "My set of Agents" }
+    let(:description) { "These Agents work together nicely!" }
+    let(:guid) { "some-guid" }
+    let(:source_url) { "http://yourhuginn.com/scenarios/2/export.json" }
+    let(:agent_list) { [agents(:jane_weather_agent), agents(:jane_rain_notifier_agent)] }
+    let(:exporter) { AgentsExporter.new(:agents => agent_list, :name => name, :description => description, :source_url => source_url, :guid => guid) }
+
+    it "outputs a structure containing name, description, the date, all agents & their links" do
+      data = exporter.as_json
+      data[:name].should == name
+      data[:description].should == description
+      data[:source_url].should == source_url
+      data[:guid].should == guid
+      Time.parse(data[:exported_at]).should be_within(2).of(Time.now.utc)
+      data[:links].should == [{ :source => 0, :receiver => 1 }]
+      data[:agents].should == agent_list.map { |agent| exporter.agent_as_json(agent) }
+      data[:agents].all? { |agent_json| agent_json[:guid].present? && agent_json[:type].present? && agent_json[:name].present? }.should be_true
+
+      data[:agents][0].should_not have_key(:propagate_immediately) # can't receive events
+      data[:agents][1].should_not have_key(:schedule) # can't be scheduled
+    end
+
+    it "does not output links to other agents outside of the incoming set" do
+      Link.create!(:source_id => agents(:jane_weather_agent).id, :receiver_id => agents(:jane_website_agent).id)
+      Link.create!(:source_id => agents(:jane_website_agent).id, :receiver_id => agents(:jane_rain_notifier_agent).id)
+
+      exporter.as_json[:links].should == [{ :source => 0, :receiver => 1 }]
+    end
+  end
+
+  describe "#filename" do
+    it "strips special characters" do
+      AgentsExporter.new(:name => "ƏfooƐƕƺbar").filename.should == "foo-bar.json"
+    end
+
+    it "strips punctuation" do
+      AgentsExporter.new(:name => "foo,bar").filename.should == "foo-bar.json"
+    end
+
+    it "strips leading and trailing dashes" do
+      AgentsExporter.new(:name => ",foo,").filename.should == "foo.json"
+    end
+
+    it "has a default when options[:name] is nil" do
+      AgentsExporter.new(:name => nil).filename.should == "exported-agents.json"
+    end
+
+    it "has a default when the result is empty" do
+      AgentsExporter.new(:name => "").filename.should == "exported-agents.json"
+      AgentsExporter.new(:name => "Ə").filename.should == "exported-agents.json"
+      AgentsExporter.new(:name => "-").filename.should == "exported-agents.json"
+      AgentsExporter.new(:name => ",,").filename.should == "exported-agents.json"
+    end
+  end
+end

+ 28 - 1
spec/models/agent_spec.rb

@@ -1,5 +1,4 @@
 require 'spec_helper'
-require 'models/concerns/working_helpers'
 
 describe Agent do
   it_behaves_like WorkingHelpers
@@ -122,6 +121,17 @@ describe Agent do
       stub(Agents::CannotBeScheduled).valid_type?("Agents::CannotBeScheduled") { true }
     end
 
+    describe Agents::SomethingSource do
+      let(:new_instance) do
+        agent = Agents::SomethingSource.new(:name => "some agent")
+        agent.user = users(:bob)
+        agent
+      end
+
+      it_behaves_like LiquidInterpolatable
+      it_behaves_like HasGuid
+    end
+
     describe ".default_schedule" do
       it "stores the default on the class" do
         Agents::SomethingSource.default_schedule.should == "2pm"
@@ -480,6 +490,23 @@ describe Agent do
         agent.should have(0).errors_on(:sources)
       end
 
+      it "should not allow scenarios owned by other people" do
+        agent = Agents::SomethingSource.new(:name => "something")
+        agent.user = users(:bob)
+
+        agent.scenario_ids = [scenarios(:bob_weather).id]
+        agent.should have(0).errors_on(:scenarios)
+
+        agent.scenario_ids = [scenarios(:bob_weather).id, scenarios(:jane_weather).id]
+        agent.should have(1).errors_on(:scenarios)
+
+        agent.scenario_ids = [scenarios(:jane_weather).id]
+        agent.should have(1).errors_on(:scenarios)
+
+        agent.user = users(:jane)
+        agent.should have(0).errors_on(:scenarios)
+      end
+
       it "validates keep_events_for" do
         agent = Agents::SomethingSource.new(:name => "something")
         agent.user = users(:bob)

+ 0 - 3
spec/models/agents/data_output_agent_spec.rb

@@ -1,11 +1,8 @@
 # encoding: utf-8
 
 require 'spec_helper'
-require 'models/concerns/liquid_interpolatable'
 
 describe Agents::DataOutputAgent do
-  it_behaves_like LiquidInterpolatable
-
   let(:agent) do
     _agent = Agents::DataOutputAgent.new(:name => 'My Data Output Agent')
     _agent.options = _agent.default_options.merge('secrets' => ['secret1', 'secret2'], 'events_to_show' => 2)

+ 0 - 3
spec/models/agents/hipchat_agent_spec.rb

@@ -1,9 +1,6 @@
 require 'spec_helper'
-require 'models/concerns/liquid_interpolatable'
 
 describe Agents::HipchatAgent do
-  it_behaves_like LiquidInterpolatable
-
   before(:each) do
     @valid_params = {
                       'auth_token' => 'token',

+ 0 - 3
spec/models/agents/human_task_agent_spec.rb

@@ -1,9 +1,6 @@
 require 'spec_helper'
-require 'models/concerns/liquid_interpolatable'
 
 describe Agents::HumanTaskAgent do
-  it_behaves_like LiquidInterpolatable
-
   before do
     @checker = Agents::HumanTaskAgent.new(:name => "my human task agent")
     @checker.options = @checker.default_options

+ 0 - 3
spec/models/agents/jabber_agent_spec.rb

@@ -1,9 +1,6 @@
 require 'spec_helper'
-require 'models/concerns/liquid_interpolatable'
 
 describe Agents::JabberAgent do
-  it_behaves_like LiquidInterpolatable
-
   let(:sent) { [] }
   let(:config) {
     {

+ 0 - 3
spec/models/agents/peak_detector_agent_spec.rb

@@ -1,9 +1,6 @@
 require 'spec_helper'
-require 'models/concerns/liquid_interpolatable'
 
 describe Agents::PeakDetectorAgent do
-  it_behaves_like LiquidInterpolatable
-
   before do
     @valid_params = {
         'name' => "my peak detector agent",

+ 0 - 3
spec/models/agents/pushbullet_agent_spec.rb

@@ -1,9 +1,6 @@
 require 'spec_helper'
-require 'models/concerns/liquid_interpolatable'
 
 describe Agents::PushbulletAgent do
-  it_behaves_like LiquidInterpolatable
-
   before(:each) do
     @valid_params = {
                       'api_key' => 'token',

+ 4 - 3
spec/models/agents/shell_command_agent_spec.rb

@@ -17,7 +17,7 @@ describe Agents::ShellCommandAgent do
     @event = Event.new
     @event.agent = agents(:jane_weather_agent)
     @event.payload = {
-      :command => "ls"
+      :cmd => "ls"
     }
     @event.save!
 
@@ -78,13 +78,14 @@ describe Agents::ShellCommandAgent do
 
   describe "#receive" do
     before do
-      stub(@checker).run_command(@valid_path, @event.payload[:command]) { ["fake ls output", "", 0] }
+      stub(@checker).run_command(@valid_path, @event.payload[:cmd]) { ["fake ls output", "", 0] }
     end
 
     it "creates events" do
+      @checker.options[:command] = "{{cmd}}"
       @checker.receive([@event])
       Event.last.payload[:path].should == @valid_path
-      Event.last.payload[:command].should == @event.payload[:command]
+      Event.last.payload[:command].should == @event.payload[:cmd]
       Event.last.payload[:output].should == "fake ls output"
     end
 

+ 2 - 4
spec/models/agents/slack_agent_spec.rb

@@ -1,9 +1,6 @@
 require 'spec_helper'
-require 'models/concerns/liquid_interpolatable'
 
 describe Agents::SlackAgent do
-  it_behaves_like LiquidInterpolatable
-
   before(:each) do
     @valid_params = {
                       'auth_token' => 'token',
@@ -51,7 +48,8 @@ describe Agents::SlackAgent do
                        username: @event.payload[:username]
                       )
       end
-      expect(@checker.receive([@event])).to_not raise_error
+
+      lambda { @checker.receive([@event]) }.should_not raise_error
     end
   end
 

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio