Merge branch 'master' of github.com:cantino/huginn

Andrew Cantino %!s(int64=11) %!d(string=hace) años
padre
commit
aaaf92791a

+ 3 - 3
Gemfile.lock

@@ -63,9 +63,9 @@ GEM
63 63
       railties (>= 3.2.6, < 5)
64 64
       warden (~> 1.2.3)
65 65
     diff-lcs (1.2.4)
66
-    dotenv (0.8.0)
67
-    dotenv-rails (0.8.0)
68
-      dotenv (= 0.8.0)
66
+    dotenv (0.9.0)
67
+    dotenv-rails (0.9.0)
68
+      dotenv (= 0.9.0)
69 69
     em-http-request (1.0.3)
70 70
       addressable (>= 2.2.3)
71 71
       cookiejar

+ 8 - 0
README.md

@@ -98,6 +98,10 @@ We assume your deployment will run over SSL. This is a very good idea! However,
98 98
 
99 99
 Huginn is provided under the MIT License.
100 100
 
101
+## Community
102
+Huginn has its own IRC channel on freenode: #huginn.
103
+Some of us are hanging out there, come and say hello.
104
+
101 105
 ## Contribution
102 106
 
103 107
 Huginn is a work in progress and is hopefully just getting started.  Please get involved!  You can [add new Agents](https://github.com/cantino/huginn/wiki/Creating-a-new-agent), expand the [Wiki](https://github.com/cantino/huginn/wiki), or help us simplify and strengthen the Agent API or core application.
@@ -105,3 +109,7 @@ Huginn is a work in progress and is hopefully just getting started.  Please get
105 109
 Please fork, add specs, and send pull requests!
106 110
 
107 111
 [![Build Status](https://travis-ci.org/cantino/huginn.png)](https://travis-ci.org/cantino/huginn) [![Code Climate](https://codeclimate.com/github/cantino/huginn.png)](https://codeclimate.com/github/cantino/huginn)
112
+
113
+
114
+[![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/cantino/huginn/trend.png)](https://bitdeli.com/free "Bitdeli Badge")
115
+

+ 37 - 0
app/concerns/email_concern.rb

@@ -0,0 +1,37 @@
1
+module EmailConcern
2
+  extend ActiveSupport::Concern
3
+
4
+  MAIN_KEYS = %w[title message text main value].map(&:to_sym)
5
+
6
+  included do
7
+    self.validate :validate_email_options
8
+  end
9
+
10
+  def validate_email_options
11
+    errors.add(:base, "subject and expected_receive_period_in_days are required") unless options[:subject].present? && options[:expected_receive_period_in_days].present?
12
+  end
13
+
14
+  def working?
15
+    last_receive_at && last_receive_at > options[:expected_receive_period_in_days].to_i.days.ago && !recent_error_logs?
16
+  end
17
+
18
+  def present(payload)
19
+    if payload.is_a?(Hash)
20
+      payload = ActiveSupport::HashWithIndifferentAccess.new(payload)
21
+      MAIN_KEYS.each do |key|
22
+        return { :title => payload[key].to_s, :entries => present_hash(payload, key) } if payload.has_key?(key)
23
+      end
24
+
25
+      { :title => "Event", :entries => present_hash(payload) }
26
+    else
27
+      { :title => payload.to_s, :entries => [] }
28
+    end
29
+  end
30
+
31
+  def present_hash(hash, skip_key = nil)
32
+    hash.to_a.sort_by {|a| a.first.to_s }.map { |k, v| "#{k}: #{v}" unless k.to_s == skip_key.to_s }.compact
33
+  end
34
+
35
+  module ClassMethods
36
+  end
37
+end

+ 7 - 27
app/models/agents/digest_email_agent.rb

@@ -1,6 +1,7 @@
1 1
 module Agents
2 2
   class DigestEmailAgent < Agent
3
-    MAIN_KEYS = %w[title message text main value].map(&:to_sym)
3
+    include EmailConcern
4
+
4 5
     default_schedule "5am"
5 6
 
6 7
     cannot_create_events!
@@ -22,45 +23,24 @@ module Agents
22 23
       }
23 24
     end
24 25
 
25
-    def working?
26
-      last_receive_at && last_receive_at > options[:expected_receive_period_in_days].to_i.days.ago && !recent_error_logs?
27
-    end
28
-
29
-    def validate_options
30
-      errors.add(:base, "subject and expected_receive_period_in_days are required") unless options[:subject].present? && options[:expected_receive_period_in_days].present?
31
-    end
32
-
33 26
     def receive(incoming_events)
34 27
       incoming_events.each do |event|
35 28
         self.memory[:queue] ||= []
36 29
         self.memory[:queue] << event.payload
30
+        self.memory[:events] ||= []
31
+        self.memory[:events] << event.id
37 32
       end
38 33
     end
39 34
 
40 35
     def check
41 36
       if self.memory[:queue] && self.memory[:queue].length > 0
37
+        ids = self.memory[:events].join(",")
42 38
         groups = self.memory[:queue].map { |payload| present(payload) }
43
-        log "Sending digest mail to #{user.email}"
39
+        log "Sending digest mail to #{user.email} with events [#{ids}]"
44 40
         SystemMailer.delay.send_message(:to => user.email, :subject => options[:subject], :headline => options[:headline], :groups => groups)
45 41
         self.memory[:queue] = []
42
+        self.memory[:events] = []
46 43
       end
47 44
     end
48
-
49
-    def present(payload)
50
-      if payload.is_a?(Hash)
51
-        payload = ActiveSupport::HashWithIndifferentAccess.new(payload)
52
-        MAIN_KEYS.each do |key|
53
-          return { :title => payload[key].to_s, :entries => present_hash(payload, key) } if payload.has_key?(key)
54
-        end
55
-
56
-        { :title => "Event", :entries => present_hash(payload) }
57
-      else
58
-        { :title => payload.to_s, :entries => [] }
59
-      end
60
-    end
61
-
62
-    def present_hash(hash, skip_key = nil)
63
-      hash.to_a.sort_by {|a| a.first.to_s }.map { |k, v| "#{k}: #{v}" unless k.to_s == skip_key.to_s }.compact
64
-    end
65 45
   end
66 46
 end

+ 32 - 0
app/models/agents/email_agent.rb

@@ -0,0 +1,32 @@
1
+module Agents
2
+  class EmailAgent < Agent
3
+    include EmailConcern
4
+
5
+    cannot_be_scheduled!
6
+    cannot_create_events!
7
+
8
+    description <<-MD
9
+      The EmailAgent sends any events it receives via email immediately.
10
+      The email will be sent to your account's address and will have a `subject` and an optional `headline` before
11
+      listing the Events.  If the Events' payloads contain a `:message`, that will be highlighted, otherwise everything in
12
+      their payloads will be shown.
13
+
14
+      Set `expected_receive_period_in_days` to the maximum amount of time that you'd expect to pass between Events being received by this Agent.
15
+    MD
16
+
17
+    def default_options
18
+      {
19
+          :subject => "You have a notification!",
20
+          :headline => "Your notification:",
21
+          :expected_receive_period_in_days => "2"
22
+      }
23
+    end
24
+
25
+    def receive(incoming_events)
26
+      incoming_events.each do |event|
27
+        log "Sending digest mail to #{user.email} with event #{event.id}"
28
+        SystemMailer.delay.send_message(:to => user.email, :subject => options[:subject], :headline => options[:headline], :groups => [present(event.payload)])
29
+      end
30
+    end
31
+  end
32
+end

+ 198 - 44
app/models/agents/human_task_agent.rb

@@ -9,9 +9,13 @@ module Agents
9 9
 
10 10
       HITs can be created in response to events, or on a schedule.  Set `trigger_on` to either `schedule` or `event`.
11 11
 
12
+      # Schedule
13
+
12 14
       The schedule of this Agent is how often it should check for completed HITs, __NOT__ how often to submit one.  To configure how often a new HIT
13 15
       should be submitted when in `schedule` mode, set `submission_period` to a number of hours.
14 16
 
17
+      # Example
18
+
15 19
       If created with an event, all HIT fields can contain interpolated values via [JSONPaths](http://goessner.net/articles/JsonPath/) placed between < and > characters.
16 20
       For example, if the incoming event was a Twitter event, you could make a HITT to rate its sentiment like this:
17 21
 
@@ -58,8 +62,52 @@ module Agents
58 62
       which contain `key` and `text`.  For _free\\_text_, the special configuration options are all optional, and are
59 63
       `default`, `min_length`, and `max_length`.
60 64
 
61
-      If all of the `questions` are of `type` _selection_, you can set `take_majority` to _true_ at the top level to
62
-      automatically select the majority vote for each question across all `assignments`.  If all selections are numeric, an `average_answer` will also be generated.
65
+      # Combining answers
66
+
67
+      There are a couple of ways to combine HITs that have multiple `assignments`, all of which involve setting `combination_mode` at the top level.
68
+
69
+      ## Taking the majority
70
+
71
+      Option 1: if all of your `questions` are of `type` _selection_, you can set `combination_mode` to `take_majority`.
72
+      This will cause the Agent to automatically select the majority vote for each question across all `assignments` and return it as `majority_answer`.
73
+      If all selections are numeric, an `average_answer` will also be generated.
74
+
75
+      Option 2: you can have the Agent ask additional human workers to rank the `assignments` and return the most highly ranked answer.
76
+      To do this, set `combination_mode` to `poll` and provide a `poll_options` object.  Here is an example:
77
+
78
+          {
79
+            "trigger_on": "schedule",
80
+            "submission_period": 12,
81
+            "combination_mode": "poll",
82
+            "poll_options": {
83
+              "title": "Take a poll about some jokes",
84
+              "instructions": "Please rank these jokes from most funny (5) to least funny (1)",
85
+              "assignments": 3,
86
+              "row_template": "<$.joke>"
87
+            },
88
+            "hit": {
89
+              "assignments": 5,
90
+              "title": "Tell a joke",
91
+              "description": "Please tell me a joke",
92
+              "reward": 0.05,
93
+              "lifetime_in_seconds": "3600",
94
+              "questions": [
95
+                {
96
+                  "type": "free_text",
97
+                  "key": "joke",
98
+                  "name": "Your joke",
99
+                  "required": "true",
100
+                  "question": "Joke",
101
+                  "min_length": "2",
102
+                  "max_length": "2000"
103
+                }
104
+              ]
105
+            }
106
+          }
107
+
108
+      Resulting events will have the original `answers`, as well as the `poll` results, and a field called `best_answer` that contains the best answer as determined by the poll.
109
+
110
+      # Other settings
63 111
 
64 112
       `lifetime_in_seconds` is the number of seconds a HIT is left on Amazon before it's automatically closed.  The default is 1 day.
65 113
 
@@ -70,6 +118,12 @@ module Agents
70 118
       Events look like:
71 119
 
72 120
           {
121
+            "answers": [
122
+              {
123
+                "feedback": "Hello!",
124
+                "sentiment": "happy"
125
+              }
126
+            ]
73 127
           }
74 128
     MD
75 129
 
@@ -97,9 +151,13 @@ module Agents
97 151
         errors.add(:base, "all questions of type 'selection' must have a selections array with selections that set 'key' and 'name'")
98 152
       end
99 153
 
100
-      if options[:take_majority] == "true" && options[:hit][:questions].any? { |question| question[:type] != "selection" }
154
+      if take_majority? && options[:hit][:questions].any? { |question| question[:type] != "selection" }
101 155
         errors.add(:base, "all questions must be of type 'selection' to use the 'take_majority' option")
102 156
       end
157
+
158
+      if create_poll?
159
+        errors.add(:base, "poll_options is required when combination_mode is set to 'poll' and must have the keys 'title', 'instructions', 'row_template', and 'assignments'") unless options[:poll_options].is_a?(Hash) && options[:poll_options][:title].present? &&  options[:poll_options][:instructions].present? && options[:poll_options][:row_template].present? && options[:poll_options][:assignments].to_i > 0
160
+      end
103 161
     end
104 162
 
105 163
     def default_options
@@ -152,69 +210,152 @@ module Agents
152 210
 
153 211
       if options[:trigger_on] == "schedule" && (memory[:last_schedule] || 0) <= Time.now.to_i - options[:submission_period].to_i * 60 * 60
154 212
         memory[:last_schedule] = Time.now.to_i
155
-        create_hit
213
+        create_basic_hit
156 214
       end
157 215
     end
158 216
 
159 217
     def receive(incoming_events)
160 218
       if options[:trigger_on] == "event"
161 219
         incoming_events.each do |event|
162
-          create_hit event
220
+          create_basic_hit event
163 221
         end
164 222
       end
165 223
     end
166 224
 
167 225
     protected
168 226
 
227
+    def take_majority?
228
+      options[:combination_mode] == "take_majority" || options[:take_majority] == "true"
229
+    end
230
+
231
+    def create_poll?
232
+      options[:combination_mode] == "poll"
233
+    end
234
+
235
+    def event_for_hit(hit_id)
236
+      if memory[:hits][hit_id.to_sym].is_a?(Hash)
237
+        Event.find_by_id(memory[:hits][hit_id.to_sym][:event_id])
238
+      else
239
+        nil
240
+      end
241
+    end
242
+
243
+    def hit_type(hit_id)
244
+      # Fix this: the Ruby process will slowly run out of RAM by symbolizing these unique keys.
245
+      if memory[:hits][hit_id.to_sym].is_a?(Hash) && memory[:hits][hit_id.to_sym][:type]
246
+        memory[:hits][hit_id.to_sym][:type].to_sym
247
+      else
248
+        :user
249
+      end
250
+    end
251
+
169 252
     def review_hits
170 253
       reviewable_hit_ids = RTurk::GetReviewableHITs.create.hit_ids
171 254
       my_reviewed_hit_ids = reviewable_hit_ids & (memory[:hits] || {}).keys.map(&:to_s)
172 255
       if reviewable_hit_ids.length > 0
173 256
         log "MTurk reports #{reviewable_hit_ids.length} HITs, of which I own [#{my_reviewed_hit_ids.to_sentence}]"
174 257
       end
258
+
175 259
       my_reviewed_hit_ids.each do |hit_id|
176 260
         hit = RTurk::Hit.new(hit_id)
177 261
         assignments = hit.assignments
178 262
 
179 263
         log "Looking at HIT #{hit_id}.  I found #{assignments.length} assignments#{" with the statuses: #{assignments.map(&:status).to_sentence}" if assignments.length > 0}"
180 264
         if assignments.length == hit.max_assignments && assignments.all? { |assignment| assignment.status == "Submitted" }
181
-          payload = { :answers => assignments.map(&:answers) }
182
-
183
-          if options[:take_majority] == "true"
184
-            counts = {}
185
-            options[:hit][:questions].each do |question|
186
-              question_counts = question[:selections].inject({}) { |memo, selection| memo[selection[:key]] = 0; memo }
187
-              assignments.each do |assignment|
188
-                answers = ActiveSupport::HashWithIndifferentAccess.new(assignment.answers)
189
-                answer = answers[question[:key]]
190
-                question_counts[answer] += 1
265
+          inbound_event = event_for_hit(hit_id)
266
+
267
+          if hit_type(hit_id) == :poll
268
+            # handle completed polls
269
+
270
+            log "Handling a poll: #{hit_id}"
271
+
272
+            scores = {}
273
+            assignments.each do |assignment|
274
+              assignment.answers.each do |index, rating|
275
+                scores[index] ||= 0
276
+                scores[index] += rating.to_i
191 277
               end
192
-              counts[question[:key]] = question_counts
193 278
             end
194
-            payload[:counts] = counts
195 279
 
196
-            majority_answer = counts.inject({}) do |memo, (key, question_counts)|
197
-              memo[key] = question_counts.to_a.sort {|a, b| a.last <=> b.last }.last.first
198
-              memo
199
-            end
200
-            payload[:majority_answer] = majority_answer
201
-
202
-            if all_questions_are_numeric?
203
-              average_answer = counts.inject({}) do |memo, (key, question_counts)|
204
-                sum = divisor = 0
205
-                question_counts.to_a.each do |num, count|
206
-                  sum += num.to_s.to_f * count
207
-                  divisor += count
280
+            top_answer = scores.to_a.sort {|b, a| a.last <=> b.last }.first.first
281
+
282
+            payload = {
283
+              :answers => memory[:hits][hit_id.to_sym][:answers],
284
+              :poll => assignments.map(&:answers),
285
+              :best_answer => memory[:hits][hit_id.to_sym][:answers][top_answer.to_i - 1]
286
+            }
287
+
288
+            event = create_event :payload => payload
289
+            log "Event emitted with answer(s) for poll", :outbound_event => event, :inbound_event => inbound_event
290
+          else
291
+            # handle normal completed HITs
292
+            payload = { :answers => assignments.map(&:answers) }
293
+
294
+            if take_majority?
295
+              counts = {}
296
+              options[:hit][:questions].each do |question|
297
+                question_counts = question[:selections].inject({}) { |memo, selection| memo[selection[:key]] = 0; memo }
298
+                assignments.each do |assignment|
299
+                  answers = ActiveSupport::HashWithIndifferentAccess.new(assignment.answers)
300
+                  answer = answers[question[:key]]
301
+                  question_counts[answer] += 1
208 302
                 end
209
-                memo[key] = sum / divisor.to_f
303
+                counts[question[:key]] = question_counts
304
+              end
305
+              payload[:counts] = counts
306
+
307
+              majority_answer = counts.inject({}) do |memo, (key, question_counts)|
308
+                memo[key] = question_counts.to_a.sort {|a, b| a.last <=> b.last }.last.first
210 309
                 memo
211 310
               end
212
-              payload[:average_answer] = average_answer
311
+              payload[:majority_answer] = majority_answer
312
+
313
+              if all_questions_are_numeric?
314
+                average_answer = counts.inject({}) do |memo, (key, question_counts)|
315
+                  sum = divisor = 0
316
+                  question_counts.to_a.each do |num, count|
317
+                    sum += num.to_s.to_f * count
318
+                    divisor += count
319
+                  end
320
+                  memo[key] = sum / divisor.to_f
321
+                  memo
322
+                end
323
+                payload[:average_answer] = average_answer
324
+              end
213 325
             end
214
-          end
215 326
 
216
-          event = create_event :payload => payload
217
-          log "Event emitted with answer(s)", :outbound_event => event, :inbound_event => Event.find_by_id(memory[:hits][hit_id.to_sym])
327
+            if create_poll?
328
+              questions = []
329
+              selections = 5.times.map { |i| { :key => i+1, :text => i+1 } }.reverse
330
+              assignments.length.times do |index|
331
+                questions << {
332
+                  :type => "selection",
333
+                  :name => "Item #{index + 1}",
334
+                  :key => index,
335
+                  :required => "true",
336
+                  :question => Utils.interpolate_jsonpaths(options[:poll_options][:row_template], assignments[index].answers),
337
+                  :selections => selections
338
+                }
339
+              end
340
+
341
+              poll_hit = create_hit :title => options[:poll_options][:title],
342
+                                    :description => options[:poll_options][:instructions],
343
+                                    :questions => questions,
344
+                                    :assignments => options[:poll_options][:assignments],
345
+                                    :lifetime_in_seconds => options[:poll_options][:lifetime_in_seconds],
346
+                                    :reward => options[:poll_options][:reward],
347
+                                    :payload => inbound_event && inbound_event.payload,
348
+                                    :metadata => { :type => :poll,
349
+                                                   :original_hit => hit_id,
350
+                                                   :answers => assignments.map(&:answers),
351
+                                                   :event_id => inbound_event && inbound_event.id }
352
+
353
+              log "Poll HIT created with ID #{poll_hit.id} and URL #{poll_hit.url}.  Original HIT: #{hit_id}", :inbound_event => inbound_event
354
+            else
355
+              event = create_event :payload => payload
356
+              log "Event emitted with answer(s)", :outbound_event => event, :inbound_event => inbound_event
357
+            end
358
+          end
218 359
 
219 360
           assignments.each(&:approve!)
220 361
           hit.dispose!
@@ -232,22 +373,35 @@ module Agents
232 373
       end
233 374
     end
234 375
 
235
-    def create_hit(event = nil)
236
-      payload = event ? event.payload : {}
237
-      title = Utils.interpolate_jsonpaths(options[:hit][:title], payload).strip
238
-      description = Utils.interpolate_jsonpaths(options[:hit][:description], payload).strip
239
-      questions = Utils.recursively_interpolate_jsonpaths(options[:hit][:questions], payload)
376
+    def create_basic_hit(event = nil)
377
+      hit = create_hit :title => options[:hit][:title],
378
+                       :description => options[:hit][:description],
379
+                       :questions => options[:hit][:questions],
380
+                       :assignments => options[:hit][:assignments],
381
+                       :lifetime_in_seconds => options[:hit][:lifetime_in_seconds],
382
+                       :reward => options[:hit][:reward],
383
+                       :payload => event && event.payload,
384
+                       :metadata => { :event_id => event && event.id }
385
+
386
+      log "HIT created with ID #{hit.id} and URL #{hit.url}", :inbound_event => event
387
+    end
388
+
389
+    def create_hit(opts = {})
390
+      payload = opts[:payload] || {}
391
+      title = Utils.interpolate_jsonpaths(opts[:title], payload).strip
392
+      description = Utils.interpolate_jsonpaths(opts[:description], payload).strip
393
+      questions = Utils.recursively_interpolate_jsonpaths(opts[:questions], payload)
240 394
       hit = RTurk::Hit.create(:title => title) do |hit|
241
-        hit.max_assignments = (options[:hit][:assignments] || 1).to_i
395
+        hit.max_assignments = (opts[:assignments] || 1).to_i
242 396
         hit.description = description
243
-        hit.lifetime = (options[:hit][:lifetime_in_seconds] || 24 * 60 * 60).to_i
397
+        hit.lifetime = (opts[:lifetime_in_seconds] || 24 * 60 * 60).to_i
244 398
         hit.question_form AgentQuestionForm.new(:title => title, :description => description, :questions => questions)
245
-        hit.reward = (options[:hit][:reward] || 0.05).to_f
399
+        hit.reward = (opts[:reward] || 0.05).to_f
246 400
         #hit.qualifications.add :approval_rate, { :gt => 80 }
247 401
       end
248 402
       memory[:hits] ||= {}
249
-      memory[:hits][hit.id] = event && event.id
250
-      log "HIT created with ID #{hit.id} and URL #{hit.url}", :inbound_event => event
403
+      memory[:hits][hit.id] = opts[:metadata] || {}
404
+      hit
251 405
     end
252 406
 
253 407
     # RTurk Question Form

+ 62 - 0
app/models/agents/webhook_agent.rb

@@ -0,0 +1,62 @@
1
+module Agents
2
+  class WebhookAgent < Agent
3
+    cannot_be_scheduled!
4
+
5
+    description  do
6
+        <<-MD
7
+        Use this Agent to create events by receiving webhooks from any source.
8
+
9
+        In order to create events with this agent, make a POST request to:
10
+        ```
11
+           https://#{ENV['DOMAIN']}/users/#{user.id}/webhooks/#{id || '<id>'}/:secret
12
+        ``` where `:secret` is specified in your options.
13
+
14
+        The
15
+
16
+        Options:
17
+
18
+          * `secret` - A token that the host will provide for authentication.
19
+          * `expected_receive_period_in_days` - How often you expect to receive
20
+            events this way. Used to determine if the agent is working.
21
+          * `payload_path` - JSONPath of the attribute of the POST body to be
22
+            used as the Event payload.
23
+      MD
24
+    end
25
+
26
+    event_description do
27
+      <<-MD
28
+        The event payload is base on the value of the `payload_path` option,
29
+        which is set to `#{options[:payload_path]}`.
30
+      MD
31
+    end
32
+
33
+    def default_options
34
+      { "secret" => "supersecretstring",
35
+        "expected_receive_period_in_days" => 1,
36
+        "payload_path" => "payload"}
37
+    end
38
+
39
+    def receive_webhook(params)
40
+      secret = params.delete(:secret)
41
+      return ["Not Authorized", 401] unless secret == options[:secret]
42
+
43
+      create_event(:payload => payload_for(params))
44
+
45
+      ['Event Created', 201]
46
+    end
47
+
48
+    def working?
49
+      event_created_within(options[:expected_receive_period_in_days]) && !recent_error_logs?
50
+    end
51
+
52
+    def validate_options
53
+      unless options[:secret].present?
54
+        errors.add(:base, "Must specify a :secret for 'Authenticating' requests")
55
+      end
56
+    end
57
+
58
+    def payload_for(params)
59
+      Utils.values_at(params, options[:payload_path]) || {}
60
+    end
61
+  end
62
+end

+ 1 - 1
config/initializers/delayed_job.rb

@@ -1,4 +1,4 @@
1
-Delayed::Worker.destroy_failed_jobs = false
1
+Delayed::Worker.destroy_failed_jobs = true
2 2
 Delayed::Worker.max_attempts = 5
3 3
 Delayed::Worker.max_run_time = 20.minutes
4 4
 Delayed::Worker.default_priority = 10

+ 38 - 2
lib/capistrano/sync.rb

@@ -1,5 +1,6 @@
1 1
 require 'yaml'
2 2
 require 'pathname'
3
+require 'dotenv'
3 4
 
4 5
 # Edited by Andrew Cantino.  Based on: https://gist.github.com/339471
5 6
 
@@ -89,13 +90,42 @@ namespace :sync do
89 90
     end
90 91
   end
91 92
 
93
+  # Used by database_config and remote_database_config to parse database configs that depend on .env files.  Depends on the dotenv-rails gem.
94
+  class EnvLoader < Dotenv::Environment
95
+    def initialize(data)
96
+      @data = data
97
+      load
98
+    end
99
+
100
+    def with_loaded_env
101
+      begin
102
+        saved_env = ENV.to_hash.dup
103
+        ENV.update(self)
104
+        yield
105
+      ensure
106
+        ENV.replace(saved_env)
107
+      end
108
+    end
109
+
110
+    def read
111
+      @data.split("\n")
112
+    end
113
+  end
114
+
92 115
   #
93 116
   # Reads the database credentials from the local config/database.yml file
94 117
   # +db+ the name of the environment to get the credentials for
95 118
   # Returns username, password, database
96 119
   #
97 120
   def database_config(db)
98
-    database = YAML::load_file('config/database.yml')
121
+    local_config = File.read('config/database.yml')
122
+    local_env = File.read('.env')
123
+
124
+    database = nil
125
+    EnvLoader.new(local_env).with_loaded_env do
126
+      database = YAML::load(ERB.new(local_config).result)
127
+    end
128
+
99 129
     return database["#{db}"]['username'], database["#{db}"]['password'], database["#{db}"]['database'], database["#{db}"]['host']
100 130
   end
101 131
 
@@ -106,7 +136,13 @@ namespace :sync do
106 136
   #
107 137
   def remote_database_config(db)
108 138
     remote_config = capture("cat #{current_path}/config/database.yml")
109
-    database = YAML::load(remote_config)
139
+    remote_env = capture("cat #{current_path}/.env")
140
+
141
+    database = nil
142
+    EnvLoader.new(remote_env).with_loaded_env do
143
+      database = YAML::load(ERB.new(remote_config).result)
144
+    end
145
+
110 146
     return database["#{db}"]['username'], database["#{db}"]['password'], database["#{db}"]['database'], database["#{db}"]['host']
111 147
   end
112 148
 

+ 1 - 0
spec/models/agents/digest_email_agent_spec.rb

@@ -41,6 +41,7 @@ describe Agents::DigestEmailAgent do
41 41
                                  { :title => "Foo", :url => "http://google.com", :bar => 2 },
42 42
                                  { "message" => "hi", :woah => "there" },
43 43
                                  { "test" => 2 }]
44
+      @checker.memory[:events] = [1,2,3,4]
44 45
       @checker.save!
45 46
 
46 47
       Agents::DigestEmailAgent.async_check(@checker.id)

+ 59 - 0
spec/models/agents/email_agent_spec.rb

@@ -0,0 +1,59 @@
1
+require 'spec_helper'
2
+
3
+describe Agents::EmailAgent do
4
+  def get_message_part(mail, content_type)
5
+    mail.body.parts.find { |p| p.content_type.match content_type }.body.raw_source
6
+  end
7
+
8
+  before do
9
+    @checker = Agents::EmailAgent.new(:name => "something", :options => { :expected_receive_period_in_days => 2, :subject => "something interesting" })
10
+    @checker.user = users(:bob)
11
+    @checker.save!
12
+  end
13
+
14
+  after do
15
+    ActionMailer::Base.deliveries = []
16
+  end
17
+
18
+  describe "#receive" do
19
+    it "immediately sends any payloads it receives" do
20
+      ActionMailer::Base.deliveries.should == []
21
+
22
+      event1 = Event.new
23
+      event1.agent = agents(:bob_rain_notifier_agent)
24
+      event1.payload = "Something you should know about"
25
+      event1.save!
26
+
27
+      event2 = Event.new
28
+      event2.agent = agents(:bob_weather_agent)
29
+      event2.payload = "Something else you should know about"
30
+      event2.save!
31
+
32
+      Agents::EmailAgent.async_receive(@checker.id, [event1.id])
33
+      Agents::EmailAgent.async_receive(@checker.id, [event2.id])
34
+
35
+      ActionMailer::Base.deliveries.count.should == 2
36
+      ActionMailer::Base.deliveries.last.to.should == ["bob@example.com"]
37
+      ActionMailer::Base.deliveries.last.subject.should == "something interesting"
38
+      get_message_part(ActionMailer::Base.deliveries.last, /plain/).strip.should == "Something else you should know about"
39
+      get_message_part(ActionMailer::Base.deliveries.first, /plain/).strip.should == "Something you should know about"
40
+    end
41
+
42
+    it "can receive complex events and send them on" do
43
+      stub_request(:any, /wunderground/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/weather.json")), :status => 200)
44
+      stub.any_instance_of(Agents::WeatherAgent).is_tomorrow?(anything) { true }
45
+      @checker.sources << agents(:bob_weather_agent)
46
+
47
+      Agent.async_check(agents(:bob_weather_agent).id)
48
+
49
+      Agent.receive!
50
+
51
+      plain_email_text = get_message_part(ActionMailer::Base.deliveries.last, /plain/).strip
52
+      html_email_text = get_message_part(ActionMailer::Base.deliveries.last, /html/).strip
53
+
54
+      plain_email_text.should =~ /avehumidity/
55
+      html_email_text.should =~ /avehumidity/
56
+    end
57
+
58
+  end
59
+end

+ 171 - 18
spec/models/agents/human_task_agent_spec.rb

@@ -108,7 +108,43 @@ describe Agents::HumanTaskAgent do
108 108
       @checker.should_not be_valid
109 109
     end
110 110
 
111
-    it "requires that all questions be of type 'selection' when `take_majority` is `true`" do
111
+    it "requires that 'poll_options' be present and populated when 'combination_mode' is set to 'poll'" do
112
+      @checker.options[:combination_mode] = "poll"
113
+      @checker.should_not be_valid
114
+      @checker.options[:poll_options] = {}
115
+      @checker.should_not be_valid
116
+      @checker.options[:poll_options] = { :title => "Take a poll about jokes",
117
+                                          :instructions => "Rank these by how funny they are",
118
+                                          :assignments => 3,
119
+                                          :row_template => "<$.joke>" }
120
+      @checker.should be_valid
121
+      @checker.options[:poll_options] = { :instructions => "Rank these by how funny they are",
122
+                                          :assignments => 3,
123
+                                          :row_template => "<$.joke>" }
124
+      @checker.should_not be_valid
125
+      @checker.options[:poll_options] = { :title => "Take a poll about jokes",
126
+                                          :assignments => 3,
127
+                                          :row_template => "<$.joke>" }
128
+      @checker.should_not be_valid
129
+      @checker.options[:poll_options] = { :title => "Take a poll about jokes",
130
+                                          :instructions => "Rank these by how funny they are",
131
+                                          :row_template => "<$.joke>" }
132
+      @checker.should_not be_valid
133
+      @checker.options[:poll_options] = { :title => "Take a poll about jokes",
134
+                                          :instructions => "Rank these by how funny they are",
135
+                                          :assignments => 3}
136
+      @checker.should_not be_valid
137
+    end
138
+
139
+    it "requires that all questions be of type 'selection' when 'combination_mode' is 'take_majority'" do
140
+      @checker.options[:combination_mode] = "take_majority"
141
+      @checker.should_not be_valid
142
+      @checker.options[:hit][:questions][1][:type] = "selection"
143
+      @checker.options[:hit][:questions][1][:selections] = @checker.options[:hit][:questions][0][:selections]
144
+      @checker.should be_valid
145
+    end
146
+
147
+    it "accepts 'take_majority': 'true' for legacy support" do
112 148
       @checker.options[:take_majority] = "true"
113 149
       @checker.should_not be_valid
114 150
       @checker.options[:hit][:questions][1][:type] = "selection"
@@ -126,7 +162,7 @@ describe Agents::HumanTaskAgent do
126 162
 
127 163
     it "should check for reviewable HITs frequently" do
128 164
       mock(@checker).review_hits.twice
129
-      mock(@checker).create_hit.once
165
+      mock(@checker).create_basic_hit.once
130 166
       @checker.check
131 167
       @checker.check
132 168
     end
@@ -135,7 +171,7 @@ describe Agents::HumanTaskAgent do
135 171
       now = Time.now
136 172
       stub(Time).now { now }
137 173
       mock(@checker).review_hits.times(3)
138
-      mock(@checker).create_hit.twice
174
+      mock(@checker).create_basic_hit.twice
139 175
       @checker.check
140 176
       now += 1 * 60 * 60
141 177
       @checker.check
@@ -144,7 +180,7 @@ describe Agents::HumanTaskAgent do
144 180
     end
145 181
 
146 182
     it "should ignore events" do
147
-      mock(@checker).create_hit(anything).times(0)
183
+      mock(@checker).create_basic_hit(anything).times(0)
148 184
       @checker.receive([events(:bob_website_agent_event)])
149 185
     end
150 186
   end
@@ -155,7 +191,7 @@ describe Agents::HumanTaskAgent do
155 191
       now = Time.now
156 192
       stub(Time).now { now }
157 193
       mock(@checker).review_hits.times(3)
158
-      mock(@checker).create_hit.times(0)
194
+      mock(@checker).create_basic_hit.times(0)
159 195
       @checker.check
160 196
       now += 1 * 60 * 60
161 197
       @checker.check
@@ -164,7 +200,7 @@ describe Agents::HumanTaskAgent do
164 200
     end
165 201
 
166 202
     it "should create HITs based on events" do
167
-      mock(@checker).create_hit(events(:bob_website_agent_event)).times(1)
203
+      mock(@checker).create_basic_hit(events(:bob_website_agent_event)).times(1)
168 204
       @checker.receive([events(:bob_website_agent_event)])
169 205
     end
170 206
   end
@@ -181,7 +217,7 @@ describe Agents::HumanTaskAgent do
181 217
       mock(hitInterface).question_form(instance_of Agents::HumanTaskAgent::AgentQuestionForm) { |agent_question_form_instance| question_form = agent_question_form_instance }
182 218
       mock(RTurk::Hit).create(:title => "Hi Joe").yields(hitInterface) { hitInterface }
183 219
 
184
-      @checker.send :create_hit, @event
220
+      @checker.send :create_basic_hit, @event
185 221
 
186 222
       hitInterface.max_assignments.should == @checker.options[:hit][:assignments]
187 223
       hitInterface.reward.should == @checker.options[:hit][:reward]
@@ -192,7 +228,7 @@ describe Agents::HumanTaskAgent do
192 228
       xml.should include("<Text>Make something for Joe</Text>")
193 229
       xml.should include("<DisplayName>Joe Question 1</DisplayName>")
194 230
 
195
-      @checker.memory[:hits][123].should == @event.id
231
+      @checker.memory[:hits][123][:event_id].should == @event.id
196 232
     end
197 233
 
198 234
     it "works without an event too" do
@@ -201,7 +237,7 @@ describe Agents::HumanTaskAgent do
201 237
       hitInterface.id = 123
202 238
       mock(hitInterface).question_form(instance_of Agents::HumanTaskAgent::AgentQuestionForm)
203 239
       mock(RTurk::Hit).create(:title => "Hi").yields(hitInterface) { hitInterface }
204
-      @checker.send :create_hit
240
+      @checker.send :create_basic_hit
205 241
       hitInterface.max_assignments.should == @checker.options[:hit][:assignments]
206 242
       hitInterface.reward.should == @checker.options[:hit][:reward]
207 243
     end
@@ -259,8 +295,8 @@ describe Agents::HumanTaskAgent do
259 295
 
260 296
       # It knows about two HITs from two different events.
261 297
       @checker.memory[:hits] = {}
262
-      @checker.memory[:hits][:"JH3132836336DHG"] = @event.id
263
-      @checker.memory[:hits][:"JH39AA63836DHG"] = event2.id
298
+      @checker.memory[:hits][:"JH3132836336DHG"] = { :event_id => @event.id }
299
+      @checker.memory[:hits][:"JH39AA63836DHG"] = { :event_id => event2.id }
264 300
 
265 301
       hit_ids = %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345]
266 302
       mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { hit_ids } } # It sees 3 HITs.
@@ -273,7 +309,7 @@ describe Agents::HumanTaskAgent do
273 309
     end
274 310
 
275 311
     it "shouldn't do anything if an assignment isn't ready" do
276
-      @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id }
312
+      @checker.memory[:hits] = { :"JH3132836336DHG" => { :event_id => @event.id } }
277 313
       mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
278 314
       assignments = [
279 315
         FakeAssignment.new(:status => "Accepted", :answers => {}),
@@ -288,11 +324,11 @@ describe Agents::HumanTaskAgent do
288 324
       @checker.send :review_hits
289 325
 
290 326
       assignments.all? {|a| a.approved == true }.should be_false
291
-      @checker.memory[:hits].should == { :"JH3132836336DHG" => @event.id }
327
+      @checker.memory[:hits].should == { :"JH3132836336DHG" => { :event_id => @event.id } }
292 328
     end
293 329
 
294 330
     it "shouldn't do anything if an assignment is missing" do
295
-      @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id }
331
+      @checker.memory[:hits] = { :"JH3132836336DHG" => { :event_id => @event.id } }
296 332
       mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
297 333
       assignments = [
298 334
         FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy", "feedback"=>"Take 2"})
@@ -306,11 +342,11 @@ describe Agents::HumanTaskAgent do
306 342
       @checker.send :review_hits
307 343
 
308 344
       assignments.all? {|a| a.approved == true }.should be_false
309
-      @checker.memory[:hits].should == { :"JH3132836336DHG" => @event.id }
345
+      @checker.memory[:hits].should == { :"JH3132836336DHG" => { :event_id => @event.id } }
310 346
     end
311 347
 
312 348
     it "should create events when all assignments are ready" do
313
-      @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id }
349
+      @checker.memory[:hits] = { :"JH3132836336DHG" => { :event_id => @event.id } }
314 350
       mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
315 351
       assignments = [
316 352
         FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"neutral", "feedback"=>""}),
@@ -337,8 +373,8 @@ describe Agents::HumanTaskAgent do
337 373
 
338 374
     describe "taking majority votes" do
339 375
       before do
340
-        @checker.options[:take_majority] = "true"
341
-        @checker.memory[:hits] = { :"JH3132836336DHG" => @event.id }
376
+        @checker.options[:combination_mode] = "take_majority"
377
+        @checker.memory[:hits] = { :"JH3132836336DHG" => { :event_id => @event.id } }
342 378
         mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
343 379
       end
344 380
 
@@ -386,6 +422,10 @@ describe Agents::HumanTaskAgent do
386 422
       end
387 423
 
388 424
       it "should also provide an average answer when all questions are numeric" do
425
+        # it should accept 'take_majority': 'true' as well for legacy support.  Demonstrating that here.
426
+        @checker.options.delete :combination_mode
427
+        @checker.options[:take_majority] = "true"
428
+
389 429
         @checker.options[:hit][:questions] = [
390 430
           {
391 431
             :type => "selection",
@@ -435,5 +475,118 @@ describe Agents::HumanTaskAgent do
435 475
         @checker.memory[:hits].should == {}
436 476
       end
437 477
     end
478
+
479
+    describe "creating and reviewing polls" do
480
+      before do
481
+        @checker.options[:combination_mode] = "poll"
482
+        @checker.options[:poll_options] = {
483
+          :title => "Hi!",
484
+          :instructions => "hello!",
485
+          :assignments => 2,
486
+          :row_template => "This is <.sentiment>"
487
+        }
488
+        @event.save!
489
+        mock(RTurk::GetReviewableHITs).create { mock!.hit_ids { %w[JH3132836336DHG JH39AA63836DHG JH39AA63836DH12345] } }
490
+      end
491
+
492
+      it "creates a poll using the row_template, message, and correct number of assignments" do
493
+        @checker.memory[:hits] = { :"JH3132836336DHG" => { :event_id => @event.id } }
494
+
495
+        # Mock out the HIT's submitted assignments.
496
+        assignments = [
497
+          FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"sad",     "feedback"=>"This is my feedback 1"}),
498
+          FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"neutral", "feedback"=>"This is my feedback 2"}),
499
+          FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy",   "feedback"=>"This is my feedback 3"}),
500
+          FakeAssignment.new(:status => "Submitted", :answers => {"sentiment"=>"happy",   "feedback"=>"This is my feedback 4"})
501
+        ]
502
+        hit = FakeHit.new(:max_assignments => 4, :assignments => assignments)
503
+        mock(RTurk::Hit).new("JH3132836336DHG") { hit }
504
+
505
+        @checker.memory[:hits][:"JH3132836336DHG"].should be_present
506
+
507
+        # Setup mocks for HIT creation
508
+
509
+        question_form = nil
510
+        hitInterface = OpenStruct.new
511
+        hitInterface.id = "JH39AA63836DH12345"
512
+        mock(hitInterface).question_form(instance_of Agents::HumanTaskAgent::AgentQuestionForm) { |agent_question_form_instance| question_form = agent_question_form_instance }
513
+        mock(RTurk::Hit).create(:title => "Hi!").yields(hitInterface) { hitInterface }
514
+
515
+        # And finally, the test.
516
+
517
+        lambda {
518
+          @checker.send :review_hits
519
+        }.should change { Event.count }.by(0) # it does not emit an event until all poll results are in
520
+
521
+        # it approves the existing assignments
522
+
523
+        assignments.all? {|a| a.approved == true }.should be_true
524
+        hit.should be_disposed
525
+
526
+        # it creates a new HIT for the poll
527
+
528
+        hitInterface.max_assignments.should == @checker.options[:poll_options][:assignments]
529
+        hitInterface.description.should == @checker.options[:poll_options][:instructions]
530
+
531
+        xml = question_form.to_xml
532
+        xml.should include("<Text>This is happy</Text>")
533
+        xml.should include("<Text>This is neutral</Text>")
534
+        xml.should include("<Text>This is sad</Text>")
535
+
536
+        @checker.save
537
+        @checker.reload
538
+        @checker.memory[:hits][:"JH3132836336DHG"].should_not be_present
539
+        @checker.memory[:hits][:"JH39AA63836DH12345"].should be_present
540
+        @checker.memory[:hits][:"JH39AA63836DH12345"][:event_id].should == @event.id
541
+        @checker.memory[:hits][:"JH39AA63836DH12345"][:type].should == :poll
542
+        @checker.memory[:hits][:"JH39AA63836DH12345"][:original_hit].should == "JH3132836336DHG"
543
+        @checker.memory[:hits][:"JH39AA63836DH12345"][:answers].length.should == 4
544
+      end
545
+
546
+      it "emits an event when all poll results are in, containing the data from the best answer, plus all others" do
547
+        original_answers = [
548
+          {:sentiment => "sad",     :feedback => "This is my feedback 1"},
549
+          {:sentiment => "neutral", :feedback => "This is my feedback 2"},
550
+          {:sentiment => "happy",   :feedback => "This is my feedback 3"},
551
+          {:sentiment => "happy",   :feedback => "This is my feedback 4"}
552
+        ]
553
+
554
+        @checker.memory[:hits] = {
555
+          :JH39AA63836DH12345 => {
556
+            :type => :poll,
557
+            :original_hit => "JH3132836336DHG",
558
+            :answers => original_answers,
559
+            :event_id => 345
560
+          }
561
+        }
562
+
563
+        # Mock out the HIT's submitted assignments.
564
+        assignments = [
565
+          FakeAssignment.new(:status => "Submitted", :answers => {"1" => "2", "2" => "5", "3" => "3", "4" => "2"}),
566
+          FakeAssignment.new(:status => "Submitted", :answers => {"1" => "3", "2" => "4", "3" => "1", "4" => "4"})
567
+        ]
568
+        hit = FakeHit.new(:max_assignments => 2, :assignments => assignments)
569
+        mock(RTurk::Hit).new("JH39AA63836DH12345") { hit }
570
+
571
+        @checker.memory[:hits][:"JH39AA63836DH12345"].should be_present
572
+
573
+        lambda {
574
+          @checker.send :review_hits
575
+        }.should change { Event.count }.by(1)
576
+
577
+        # It emits an event
578
+
579
+        @checker.events.last.payload[:answers].should == original_answers
580
+        @checker.events.last.payload[:poll].should == [{:"1" => "2", :"2" => "5", :"3" => "3", :"4" => "2"}, {:"1" => "3", :"2" => "4", :"3" => "1", :"4" => "4"}]
581
+        @checker.events.last.payload[:best_answer].should == {:sentiment => "neutral", :feedback => "This is my feedback 2"}
582
+
583
+        # it approves the existing assignments
584
+
585
+        assignments.all? {|a| a.approved == true }.should be_true
586
+        hit.should be_disposed
587
+
588
+        @checker.memory[:hits].should be_empty
589
+      end
590
+    end
438 591
   end
439 592
 end

+ 33 - 0
spec/models/agents/webhook_agent_spec.rb

@@ -0,0 +1,33 @@
1
+require 'spec_helper'
2
+
3
+describe Agents::WebhookAgent do
4
+  let(:agent) do
5
+    _agent = Agents::WebhookAgent.new(:name => 'webhook',
6
+             :options => {:secret => :foobar, :payload_path => '$'})
7
+    _agent.user = users(:bob)
8
+    _agent.save!
9
+    _agent
10
+  end
11
+  let(:payload) { {'some' => 'info'} }
12
+
13
+  after { agent.destroy }
14
+
15
+  describe 'receive_webhook' do
16
+    it 'should create event if secret matches' do
17
+      out = nil
18
+      lambda {
19
+        out = agent.receive_webhook({:secret => :foobar, :payload => payload})
20
+      }.should change { Event.count }.by(1)
21
+      out.should eq(['Event Created', 201])
22
+      Event.last.payload.should eq([{'payload' => payload}])
23
+    end
24
+
25
+    it 'should not create event if secrets dont match' do
26
+      out = nil
27
+      lambda {
28
+        out = agent.receive_webhook({:secret => :bazbat, :payload => payload})
29
+      }.should change { Event.count }.by(0)
30
+      out.should eq(['Not Authorized', 401])
31
+    end
32
+  end
33
+end