Merge branch 'master' into threaded-background-workers

Dominik Sander 10 年之前
父節點
當前提交
a505a1c211
共有 33 個文件被更改,包括 1015 次插入190 次删除
  1. 1 0
      Gemfile
  2. 2 0
      Gemfile.lock
  3. 1 1
      app/assets/javascripts/application.js.coffee.erb
  4. 15 4
      app/assets/stylesheets/application.css.scss.erb
  5. 0 39
      app/concerns/json_path_options_overwritable.rb
  6. 49 0
      app/concerns/liquid_interpolatable.rb
  7. 4 1
      app/controllers/application_controller.rb
  8. 2 2
      app/helpers/application_helper.rb
  9. 0 4
      app/models/agent.rb
  10. 7 5
      app/models/agents/data_output_agent.rb
  11. 13 12
      app/models/agents/event_formatting_agent.rb
  12. 3 13
      app/models/agents/hipchat_agent.rb
  13. 10 8
      app/models/agents/human_task_agent.rb
  14. 453 0
      app/models/agents/imap_folder_agent.rb
  15. 7 3
      app/models/agents/jabber_agent.rb
  16. 160 0
      app/models/agents/jira_agent.rb
  17. 4 2
      app/models/agents/peak_detector_agent.rb
  18. 8 15
      app/models/agents/pushbullet_agent.rb
  19. 6 5
      app/models/agents/translation_agent.rb
  20. 6 4
      app/models/agents/trigger_agent.rb
  21. 4 3
      app/models/agents/twitter_publish_agent.rb
  22. 41 0
      app/views/agents/_action_menu.html.erb
  23. 13 17
      app/views/agents/index.html.erb
  24. 7 32
      app/views/agents/show.html.erb
  25. 1 1
      app/views/layouts/_messages.html.erb
  26. 45 0
      db/migrate/20140505201716_migrate_agents_to_liquid_templating.rb
  27. 11 11
      deployment/Cheffile.lock
  28. 11 4
      deployment/site-cookbooks/huginn_development/recipes/default.rb
  29. 11 4
      deployment/site-cookbooks/huginn_production/recipes/default.rb
  30. 78 0
      lib/liquid_migrator.rb
  31. 22 0
      spec/data_fixtures/imap1.eml
  32. 20 0
      spec/data_fixtures/imap2.eml
  33. 0 0
      spec/data_fixtures/jira.json

+ 1 - 0
Gemfile

@@ -31,6 +31,7 @@ gem 'json', '~> 1.8.1'
31 31
 gem 'jsonpath', '~> 0.5.3'
32 32
 gem 'twilio-ruby', '~> 3.11.5'
33 33
 gem 'ruby-growl', '~> 4.1.0'
34
+gem 'liquid', '~> 2.6.1'
34 35
 
35 36
 gem 'delayed_job', '~> 4.0.0'
36 37
 gem 'delayed_job_active_record', '~> 4.0.0'

+ 2 - 0
Gemfile.lock

@@ -148,6 +148,7 @@ GEM
148 148
       activesupport (>= 3.0.0)
149 149
     kramdown (1.3.3)
150 150
     libv8 (3.16.14.3)
151
+    liquid (2.6.1)
151 152
     macaddr (1.7.1)
152 153
       systemu (~> 2.6.2)
153 154
     mail (2.5.4)
@@ -338,6 +339,7 @@ DEPENDENCIES
338 339
   jsonpath (~> 0.5.3)
339 340
   kaminari (~> 0.15.1)
340 341
   kramdown (~> 1.3.3)
342
+  liquid (~> 2.6.1)
341 343
   mysql2 (~> 0.3.15)
342 344
   nokogiri (~> 1.6.1)
343 345
   protected_attributes (~> 1.0.7)

+ 1 - 1
app/assets/javascripts/application.js.coffee.erb

@@ -127,7 +127,7 @@ $(document).ready ->
127 127
 
128 128
   if tab = window.location.href.match(/tab=(\w+)\b/i)?[1]
129 129
     if tab in ["details", "logs"]
130
-      $(".agent-show .nav-tabs li a[href='##{tab}']").click()
130
+      $(".agent-show .nav-pills li a[href='##{tab}']").click()
131 131
 
132 132
   # Editing Agents
133 133
   $("#agent_source_ids").on "change", showEventDescriptions

+ 15 - 4
app/assets/stylesheets/application.css.scss.erb

@@ -136,10 +136,8 @@ span.not-applicable:after {
136 136
 
137 137
 // Disabled
138 138
 
139
-tr.agent-disabled {
140
-  td {
141
-    opacity: 0.5;
142
-  }
139
+.agent-disabled {
140
+  opacity: 0.5;
143 141
 }
144 142
 
145 143
 // Fix JSON Editor
@@ -147,3 +145,16 @@ tr.agent-disabled {
147 145
 .json-editor blockquote {
148 146
   font-size: 14px;
149 147
 }
148
+
149
+// Bootstrappy colour styles
150
+.color-danger {
151
+  color: #d9534f;
152
+}
153
+
154
+.color-warning {
155
+  color: #f0ad4e;
156
+}
157
+
158
+.color-success {
159
+  color: #5cb85c;
160
+}

+ 0 - 39
app/concerns/json_path_options_overwritable.rb

@@ -1,39 +0,0 @@
1
-module JsonPathOptionsOverwritable
2
-  extend ActiveSupport::Concern
3
-  # Using this concern allows providing optional `<attribute>_path` options hash
4
-  # attributes which will then (if not blank) be interpolated using the provided JSONPath.
5
-  #
6
-  # Example options Hash:
7
-  # {
8
-  #   name: 'Huginn',
9
-  #   name_path: '$.name',
10
-  #   title: 'Hello from Huginn'
11
-  #   title_path: ''
12
-  # }
13
-  # Example event payload:
14
-  # {
15
-  #   name: 'dynamic huginn'
16
-  # }
17
-  # calling agent.merge_json_path_options(event) returns the following hash:
18
-  # {
19
-  #   name: 'dynamic huginn'
20
-  #   title: 'Hello from Huginn'
21
-  # }
22
-
23
-  private
24
-  def merge_json_path_options(event)
25
-    options.select { |k, v| options_with_path.include? k}.tap do |merged_options|
26
-      options_with_path.each do |a|
27
-        merged_options[a] = select_option(event, a)
28
-      end
29
-    end
30
-  end
31
-
32
-  def select_option(event, a)
33
-    if options[a.to_s + '_path'].present?
34
-      Utils.value_at(event.payload, options[a.to_s + '_path'])
35
-    else
36
-      options[a]
37
-    end
38
-  end
39
-end

+ 49 - 0
app/concerns/liquid_interpolatable.rb

@@ -0,0 +1,49 @@
1
+module LiquidInterpolatable
2
+  extend ActiveSupport::Concern
3
+
4
+  def interpolate_options(options, payload)
5
+    case options.class.to_s
6
+    when 'String'
7
+      interpolate_string(options, payload)
8
+    when 'ActiveSupport::HashWithIndifferentAccess', 'Hash'
9
+      duped_options = options.dup
10
+      duped_options.each do |key, value|
11
+        duped_options[key] = interpolate_options(value, payload)
12
+      end
13
+    when 'Array'
14
+      options.collect do |value|
15
+        interpolate_options(value, payload)
16
+      end
17
+    end
18
+  end
19
+
20
+  def interpolate_string(string, payload)
21
+    Liquid::Template.parse(string).render!(payload, registers: {agent: self})
22
+  end
23
+
24
+  require 'uri'
25
+  # Percent encoding for URI conforming to RFC 3986.
26
+  # Ref: http://tools.ietf.org/html/rfc3986#page-12
27
+  module Filters
28
+    def uri_escape(string)
29
+      CGI::escape string
30
+    end
31
+  end
32
+  Liquid::Template.register_filter(LiquidInterpolatable::Filters)
33
+
34
+  module Tags
35
+    class Credential < Liquid::Tag
36
+      def initialize(tag_name, name, tokens)
37
+        super
38
+        @credential_name = name.strip
39
+      end
40
+
41
+      def render(context)
42
+        credential = context.registers[:agent].credential(@credential_name)
43
+        raise "No user credential named '#{@credential_name}' defined" if credential.nil?
44
+        credential
45
+      end
46
+    end
47
+  end
48
+  Liquid::Template.register_tag('credential', LiquidInterpolatable::Tags::Credential)
49
+end

+ 4 - 1
app/controllers/application_controller.rb

@@ -7,7 +7,10 @@ class ApplicationController < ActionController::Base
7 7
   helper :all
8 8
 
9 9
   protected
10
+
10 11
   def configure_permitted_parameters
11
-    devise_parameter_sanitizer.for(:sign_up) << [:username, :email, :invitation_code]
12
+    devise_parameter_sanitizer.for(:sign_up) { |u| u.permit(:username, :email, :password, :password_confirmation, :remember_me, :invitation_code) }
13
+    devise_parameter_sanitizer.for(:sign_in) { |u| u.permit(:login, :username, :email, :password, :remember_me) }
14
+    devise_parameter_sanitizer.for(:account_update) { |u| u.permit(:username, :email, :password, :password_confirmation, :current_password) }
12 15
   end
13 16
 end

+ 2 - 2
app/helpers/application_helper.rb

@@ -9,11 +9,11 @@ module ApplicationHelper
9 9
 
10 10
   def working(agent)
11 11
     if agent.disabled?
12
-      '<span class="label label-warning">Disabled</span>'.html_safe
12
+      link_to 'Disabled', agent_path(agent), :class => 'label label-warning'
13 13
     elsif agent.working?
14 14
       '<span class="label label-success">Yes</span>'.html_safe
15 15
     else
16
-      link_to '<span class="label btn-danger">No</span>'.html_safe, agent_path(agent, :tab => (agent.recent_error_logs? ? 'logs' : 'details'))
16
+      link_to 'No', agent_path(agent, :tab => (agent.recent_error_logs? ? 'logs' : 'details')), :class => 'label label-danger'
17 17
     end
18 18
   end
19 19
 end

+ 0 - 4
app/models/agent.rb

@@ -121,10 +121,6 @@ class Agent < ActiveRecord::Base
121 121
     end
122 122
   end
123 123
 
124
-  def make_message(payload, message = options[:message])
125
-    message.gsub(/<([^>]+)>/) { Utils.value_at(payload, $1) || "??" }
126
-  end
127
-
128 124
   def trigger_web_request(params, method, format)
129 125
     if respond_to?(:receive_webhook)
130 126
       Rails.logger.warn "DEPRECATED: The .receive_webhook method is deprecated, please switch your Agent to use .receive_web_request."

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

@@ -1,5 +1,7 @@
1 1
 module Agents
2 2
   class DataOutputAgent < Agent
3
+    include LiquidInterpolatable
4
+
3 5
     cannot_be_scheduled!
4 6
 
5 7
     description  do
@@ -19,7 +21,7 @@ module Agents
19 21
 
20 22
           * `secrets` - An array of tokens that the requestor must provide for light-weight authentication.
21 23
           * `expected_receive_period_in_days` - How often you expect data to be received by this Agent from other Agents.
22
-          * `template` - A JSON object representing a mapping between item output keys and incoming event JSONPath values.  JSONPath values must start with `$`, or can be interpolated between `<` and `>` characters.  The `item` key will be repeated for every Event.
24
+          * `template` - A JSON object representing a mapping between item output keys and incoming event values. Use [Liquid](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) to format the values. The `item` key will be repeated for every Event.
23 25
       MD
24 26
     end
25 27
 
@@ -31,9 +33,9 @@ module Agents
31 33
           "title" => "XKCD comics as a feed",
32 34
           "description" => "This is a feed of recent XKCD comics, generated by Huginn",
33 35
           "item" => {
34
-            "title" => "$.title",
35
-            "description" => "Secret hovertext: <$.hovertext>",
36
-            "link" => "$.url",
36
+            "title" => "{{title}}",
37
+            "description" => "Secret hovertext: {{hovertext}}",
38
+            "link" => "{{url}}",
37 39
           }
38 40
         }
39 41
       }
@@ -82,7 +84,7 @@ module Agents
82 84
     def receive_web_request(params, method, format)
83 85
       if options['secrets'].include?(params['secret'])
84 86
         items = received_events.order('id desc').limit(events_to_show).map do |event|
85
-          interpolated = Utils.recursively_interpolate_jsonpaths(options['template']['item'], event.payload, :leading_dollarsign_is_jsonpath => true)
87
+          interpolated = interpolate_options(options['template']['item'], event.payload)
86 88
           interpolated['guid'] = event.id
87 89
           interpolated['pubDate'] = event.created_at.rfc2822.to_s
88 90
           interpolated

+ 13 - 12
app/models/agents/event_formatting_agent.rb

@@ -1,5 +1,6 @@
1 1
 module Agents
2 2
   class EventFormattingAgent < Agent
3
+    include LiquidInterpolatable
3 4
     cannot_be_scheduled!
4 5
 
5 6
     description <<-MD
@@ -24,11 +25,11 @@ module Agents
24 25
       You can use an Event Formatting Agent's `instructions` setting to do this in the following way:
25 26
 
26 27
           "instructions": {
27
-            "message": "Today's conditions look like <$.conditions> with a high temperature of <$.high.celsius> degrees Celsius.",
28
-            "subject": "$.data"
28
+            "message": "Today's conditions look like {{conditions}} with a high temperature of {{high.celsius}} degrees Celsius.",
29
+            "subject": "{{data}}"
29 30
           }
30 31
 
31
-      JSONPaths must be between < and > . Make sure that you don't use these symbols anywhere else.
32
+      Have a look at the [Wiki](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) to learn more about liquid templating.
32 33
 
33 34
       Events generated by this possible Event Formatting Agent will look like:
34 35
 
@@ -42,7 +43,7 @@ module Agents
42 43
           {
43 44
             "matchers": [
44 45
               {
45
-                "path": "$.date.pretty",
46
+                "path": "{{date.pretty}}",
46 47
                 "regexp": "\\A(?<time>\\d\\d:\\d\\d [AP]M [A-Z]+)",
47 48
                 "to": "pretty_date",
48 49
               }
@@ -60,18 +61,18 @@ module Agents
60 61
       So you can use it in `instructions` like this:
61 62
 
62 63
           "instructions": {
63
-            "message": "Today's conditions look like <$.conditions> with a high temperature of <$.high.celsius> degrees Celsius according to the forecast at <$.pretty_date.time>.",
64
-            "subject": "$.data"
64
+            "message": "Today's conditions look like <$.conditions> with a high temperature of {{high.celsius}} degrees Celsius according to the forecast at {{pretty_date.time}}.",
65
+            "subject": "{{data}}"
65 66
           }
66 67
 
67 68
       If you want to retain original contents of events and only add new keys, then set `mode` to `merge`, otherwise set it to `clean`.
68 69
 
69 70
       By default, the output event will have `agent` and `created_at` fields added as well, reflecting the original Agent type and Event creation time.  You can skip these outputs by setting `skip_agent` and `skip_created_at` to `true`.
70 71
 
71
-      To CGI escape output (for example when creating a link), prefix with `escape`, like so:
72
+      To CGI escape output (for example when creating a link), use the Liquid `uri_escape` filter, like so:
72 73
 
73 74
           {
74
-            "message": "A peak was on Twitter in <$.group_by>.  Search: https://twitter.com/search?q=<escape $.group_by>"
75
+            "message": "A peak was on Twitter in {{group_by}}.  Search: https://twitter.com/search?q={{group_by | uri_escape}}"
75 76
           }
76 77
     MD
77 78
 
@@ -88,8 +89,8 @@ module Agents
88 89
     def default_options
89 90
       {
90 91
         'instructions' => {
91
-          'message' =>  "You received a text <$.text> from <$.fields.from>",
92
-          'some_other_field' => "Looks like the weather is going to be <$.fields.weather>"
92
+          'message' =>  "You received a text {{text}} from {{fields.from}}",
93
+          'some_other_field' => "Looks like the weather is going to be {{fields.weather}}"
93 94
         },
94 95
         'matchers' => [],
95 96
         'mode' => "clean",
@@ -106,7 +107,7 @@ module Agents
106 107
       incoming_events.each do |event|
107 108
         formatted_event = options['mode'].to_s == "merge" ? event.payload.dup : {}
108 109
         payload = perform_matching(event.payload)
109
-        options['instructions'].each_pair {|key, value| formatted_event[key] = Utils.interpolate_jsonpaths(value, payload) }
110
+        formatted_event.merge! interpolate_options(options['instructions'], payload)
110 111
         formatted_event['agent'] = Agent.find(event.agent_id).type.slice!(8..-1) unless options['skip_agent'].to_s == "true"
111 112
         formatted_event['created_at'] = event.created_at unless options['skip_created_at'].to_s == "true"
112 113
         create_event :payload => formatted_event
@@ -161,7 +162,7 @@ module Agents
161 162
             re = Regexp.new(regexp)
162 163
             proc { |hash|
163 164
               mhash = {}
164
-              value = Utils.value_at(hash, path)
165
+              value = interpolate_string(path, hash)
165 166
               if value.is_a?(String) && (m = re.match(value))
166 167
                 m.to_a.each_with_index { |s, i|
167 168
                   mhash[i.to_s] = s

+ 3 - 13
app/models/agents/hipchat_agent.rb

@@ -1,6 +1,6 @@
1 1
 module Agents
2 2
   class HipchatAgent < Agent
3
-    include JsonPathOptionsOverwritable
3
+    include LiquidInterpolatable
4 4
 
5 5
     cannot_be_scheduled!
6 6
     cannot_create_events!
@@ -18,22 +18,17 @@ module Agents
18 18
       If you want your message to notify the room members change `notify` to "true".
19 19
       Modify the background color of your message via the `color` attribute (one of "yellow", "red", "green", "purple", "gray", or "random")
20 20
 
21
-      If you want to specify either of those attributes per event, you can provide a [JSONPath](http://goessner.net/articles/JsonPath/) for each of them (except the `auth_token`).
21
+      Have a look at the [Wiki](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) to learn more about liquid templating.
22 22
     MD
23 23
 
24 24
     def default_options
25 25
       {
26 26
         'auth_token' => '',
27 27
         'room_name' => '',
28
-        'room_name_path' => '',
29 28
         'username' => "Huginn",
30
-        'username_path' => '',
31 29
         'message' => "Hello from Huginn!",
32
-        'message_path' => '',
33 30
         'notify' => false,
34
-        'notify_path' => '',
35 31
         'color' => 'yellow',
36
-        'color_path' => '',
37 32
       }
38 33
     end
39 34
 
@@ -49,14 +44,9 @@ module Agents
49 44
     def receive(incoming_events)
50 45
       client = HipChat::Client.new(options[:auth_token])
51 46
       incoming_events.each do |event|
52
-        mo = merge_json_path_options event
47
+        mo = interpolate_options options, event.payload
53 48
         client[mo[:room_name]].send(mo[:username], mo[:message], :notify => mo[:notify].to_s == 'true' ? 1 : 0, :color => mo[:color])
54 49
       end
55 50
     end
56
-
57
-    private
58
-    def options_with_path
59
-      [:room_name, :username, :message, :notify, :color]
60
-    end
61 51
   end
62 52
 end

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

@@ -2,6 +2,8 @@ require 'rturk'
2 2
 
3 3
 module Agents
4 4
   class HumanTaskAgent < Agent
5
+    include LiquidInterpolatable
6
+
5 7
     default_schedule "every_10m"
6 8
 
7 9
     description <<-MD
@@ -16,7 +18,7 @@ module Agents
16 18
 
17 19
       # Example
18 20
 
19
-      If created with an event, all HIT fields can contain interpolated values via [JSONPaths](http://goessner.net/articles/JsonPath/) placed between < and > characters.
21
+      If created with an event, all HIT fields can contain interpolated values via [liquid templating](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid).
20 22
       For example, if the incoming event was a Twitter event, you could make a HITT to rate its sentiment like this:
21 23
 
22 24
           {
@@ -25,7 +27,7 @@ module Agents
25 27
             "hit": {
26 28
               "assignments": 1,
27 29
               "title": "Sentiment evaluation",
28
-              "description": "Please rate the sentiment of this message: '<$.message>'",
30
+              "description": "Please rate the sentiment of this message: '{{message}}'",
29 31
               "reward": 0.05,
30 32
               "lifetime_in_seconds": "3600",
31 33
               "questions": [
@@ -83,7 +85,7 @@ module Agents
83 85
               "title": "Take a poll about some jokes",
84 86
               "instructions": "Please rank these jokes from most funny (5) to least funny (1)",
85 87
               "assignments": 3,
86
-              "row_template": "<$.joke>"
88
+              "row_template": "{{joke}}"
87 89
             },
88 90
             "hit": {
89 91
               "assignments": 5,
@@ -168,7 +170,7 @@ module Agents
168 170
           {
169 171
             'assignments' => 1,
170 172
             'title' => "Sentiment evaluation",
171
-            'description' => "Please rate the sentiment of this message: '<$.message>'",
173
+            'description' => "Please rate the sentiment of this message: '{{message}}'",
172 174
             'reward' => 0.05,
173 175
             'lifetime_in_seconds' => 24 * 60 * 60,
174 176
             'questions' =>
@@ -332,7 +334,7 @@ module Agents
332 334
                   'name' => "Item #{index + 1}",
333 335
                   'key' => index,
334 336
                   'required' => "true",
335
-                  'question' => Utils.interpolate_jsonpaths(options['poll_options']['row_template'], assignments[index].answers),
337
+                  'question' => interpolate_string(options['poll_options']['row_template'], assignments[index].answers),
336 338
                   'selections' => selections
337 339
                 }
338 340
               end
@@ -387,9 +389,9 @@ module Agents
387 389
 
388 390
     def create_hit(opts = {})
389 391
       payload = opts['payload'] || {}
390
-      title = Utils.interpolate_jsonpaths(opts['title'], payload).strip
391
-      description = Utils.interpolate_jsonpaths(opts['description'], payload).strip
392
-      questions = Utils.recursively_interpolate_jsonpaths(opts['questions'], payload)
392
+      title = interpolate_string(opts['title'], payload).strip
393
+      description = interpolate_string(opts['description'], payload).strip
394
+      questions = interpolate_options(opts['questions'], payload)
393 395
       hit = RTurk::Hit.create(:title => title) do |hit|
394 396
         hit.max_assignments = (opts['assignments'] || 1).to_i
395 397
         hit.description = description

+ 453 - 0
app/models/agents/imap_folder_agent.rb

@@ -0,0 +1,453 @@
1
+require 'delegate'
2
+require 'net/imap'
3
+require 'mail'
4
+
5
+module Agents
6
+  class ImapFolderAgent < Agent
7
+    cannot_receive_events!
8
+
9
+    default_schedule "every_30m"
10
+
11
+    description <<-MD
12
+
13
+      The ImapFolderAgent checks an IMAP server in specified folders
14
+      and creates Events based on new unread mails.
15
+
16
+      Specify an IMAP server to connect with `host`, and set `ssl` to
17
+      true if the server supports IMAP over SSL.  Specify `port` if
18
+      you need to connect to a port other than standard (143 or 993
19
+      depending on the `ssl` value).
20
+
21
+      Specify login credentials in `username` and `password`.
22
+
23
+      List the names of folders to check in `folders`.
24
+
25
+      To narrow mails by conditions, build a `conditions` hash with
26
+      the following keys:
27
+
28
+      - "subject"
29
+      - "body"
30
+
31
+          Specify a regular expression to match against the decoded
32
+          subject/body of each mail.
33
+
34
+          Use the `(?i)` directive for case-insensitive search.  For
35
+          example, a pattern `(?i)alert` will match "alert", "Alert"
36
+          or "ALERT".  You can also make only a part of a pattern to
37
+          work case-insensitively: `Re: (?i:alert)` will match either
38
+          "Re: Alert" or "Re: alert", but not "RE: alert".
39
+
40
+          When a mail has multiple non-attachment text parts, they are
41
+          prioritized according to the `mime_types` option (which see
42
+          below) and the first part that matches a "body" pattern, if
43
+          specified, will be chosen as the "body" value in a created
44
+          event.
45
+
46
+          Named captues will appear in the "matches" hash in a created
47
+          event.
48
+
49
+      - "from", "to", "cc"
50
+
51
+          Specify a shell glob pattern string that is matched against
52
+          mail addresses extracted from the corresponding header
53
+          values of each mail.
54
+
55
+          Patterns match addresses in case insensitive manner.
56
+
57
+          Multiple pattern strings can be specified in an array, in
58
+          which case a mail is selected if any of the patterns
59
+          matches. (i.e. patterns are OR'd)
60
+
61
+      - "mime_types"
62
+
63
+          Specify an array of MIME types to tell which non-attachment
64
+          part of a mail among its text/* parts should be used as mail
65
+          body.  The default value is `['text/plain', 'text/enriched',
66
+          'text/html']`.
67
+
68
+      - "has_attachment"
69
+
70
+          Setting this to true or false means only mails that does or does
71
+          not have an attachment are selected.
72
+
73
+          If this key is unspecified or set to null, it is ignored.
74
+
75
+      Set `mark_as_read` to true to mark found mails as read.
76
+
77
+      Each agent instance memorizes a list of unread mails that are
78
+      found in the last run, so even if you change a set of conditions
79
+      so that it matches mails that are missed previously, they will
80
+      not show up as new events.  Also, in order to avoid duplicated
81
+      notification it keeps a list of Message-Id's of 100 most recent
82
+      mails, so if multiple mails of the same Message-Id are found,
83
+      you will only see one event out of them.
84
+    MD
85
+
86
+    event_description <<-MD
87
+      Events look like this:
88
+
89
+          {
90
+            "folder": "INBOX",
91
+            "subject": "...",
92
+            "from": "Nanashi <nanashi.gombeh@example.jp>",
93
+            "to": ["Jane <jane.doe@example.com>"],
94
+            "cc": [],
95
+            "date": "2014-05-10T03:47:20+0900",
96
+            "mime_type": "text/plain",
97
+            "body": "Hello,\n\n...",
98
+            "matches": {
99
+            }
100
+          }
101
+    MD
102
+
103
+    IDCACHE_SIZE = 100
104
+
105
+    FNM_FLAGS = [:FNM_CASEFOLD, :FNM_EXTGLOB].inject(0) { |flags, sym|
106
+      if File.const_defined?(sym)
107
+        flags | File.const_get(sym)
108
+      else
109
+        flags
110
+      end
111
+    }
112
+
113
+    def working?
114
+      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
115
+    end
116
+
117
+    def default_options
118
+      {
119
+        'expected_update_period_in_days' => "1",
120
+        'host' => 'imap.gmail.com',
121
+        'ssl' => true,
122
+        'username' => 'your.account',
123
+        'password' => 'your.password',
124
+        'folders' => %w[INBOX],
125
+        'conditions' => {}
126
+      }
127
+    end
128
+
129
+    def validate_options
130
+      %w[host username password].each { |key|
131
+        String === options[key] or
132
+          errors.add(:base, '%s is required and must be a string' % key)
133
+      }
134
+
135
+      if options['port'].present?
136
+        errors.add(:base, "port must be a positive integer") unless is_positive_integer?(options['port'])
137
+      end
138
+
139
+      %w[ssl mark_as_read].each { |key|
140
+        if options[key].present?
141
+          case options[key]
142
+          when true, false
143
+          else
144
+            errors.add(:base, '%s must be a boolean value' % key)
145
+          end
146
+        end
147
+      }
148
+
149
+      case mime_types = options['mime_types']
150
+      when nil
151
+      when Array
152
+        mime_types.all? { |mime_type|
153
+          String === mime_type && mime_type.start_with?('text/')
154
+        } or errors.add(:base, 'mime_types may only contain strings that match "text/*".')
155
+        if mime_types.empty?
156
+          errors.add(:base, 'mime_types should not be empty')
157
+        end
158
+      else
159
+        errors.add(:base, 'mime_types must be an array')
160
+      end
161
+
162
+      case folders = options['folders']
163
+      when nil
164
+      when Array
165
+        folders.all? { |folder|
166
+          String === folder
167
+        } or errors.add(:base, 'folders may only contain strings')
168
+        if folders.empty?
169
+          errors.add(:base, 'folders should not be empty')
170
+        end
171
+      else
172
+        errors.add(:base, 'folders must be an array')
173
+      end
174
+
175
+      case conditions = options['conditions']
176
+      when nil
177
+      when Hash
178
+        conditions.each { |key, value|
179
+          value.present? or next
180
+          case key
181
+          when 'subject', 'body'
182
+            case value
183
+            when String
184
+              begin
185
+                Regexp.new(value)
186
+              rescue
187
+                errors.add(:base, 'conditions.%s contains an invalid regexp' % key)
188
+              end
189
+            else
190
+              errors.add(:base, 'conditions.%s contains a non-string object' % key)
191
+            end
192
+          when 'from', 'to', 'cc'
193
+            Array(value).each { |pattern|
194
+              case pattern
195
+              when String
196
+                begin
197
+                  glob_match?(pattern, '')
198
+                rescue
199
+                  errors.add(:base, 'conditions.%s contains an invalid glob pattern' % key)
200
+                end
201
+              else
202
+                errors.add(:base, 'conditions.%s contains a non-string object' % key)
203
+              end
204
+            }
205
+          when 'has_attachment'
206
+            case value
207
+            when true, false
208
+            else
209
+              errors.add(:base, 'conditions.%s must be a boolean value or null' % key)
210
+            end
211
+          end
212
+        }
213
+      else
214
+        errors.add(:base, 'conditions must be a hash')
215
+      end
216
+
217
+      if options['expected_update_period_in_days'].present?
218
+        errors.add(:base, "Invalid expected_update_period_in_days format") unless is_positive_integer?(options['expected_update_period_in_days'])
219
+      end
220
+    end
221
+
222
+    def check
223
+      # 'seen' keeps a hash of { uidvalidity => uids, ... } which
224
+      # lists unread mails in watched folders.
225
+      seen = memory['seen'] || {}
226
+      new_seen = Hash.new { |hash, key|
227
+        hash[key] = []
228
+      }
229
+
230
+      # 'notified' keeps an array of message-ids of {IDCACHE_SIZE}
231
+      # most recent notified mails.
232
+      notified = memory['notified'] || []
233
+
234
+      each_unread_mail { |mail|
235
+        new_seen[mail.uidvalidity] << mail.uid
236
+
237
+        next if (uids = seen[mail.uidvalidity]) && uids.include?(mail.uid)
238
+
239
+        body_parts = mail.body_parts(mime_types)
240
+        matched_part = nil
241
+        matches = {}
242
+
243
+        options['conditions'].all? { |key, value|
244
+          case key
245
+          when 'subject'
246
+            value.present? or next true
247
+            re = Regexp.new(value)
248
+            if m = re.match(mail.subject)
249
+              m.names.each { |name|
250
+                matches[name] = m[name]
251
+              }
252
+              true
253
+            else
254
+              false
255
+            end
256
+          when 'body'
257
+            value.present? or next true
258
+            re = Regexp.new(value)
259
+            matched_part = body_parts.find { |part|
260
+               if m = re.match(part.decoded)
261
+                 m.names.each { |name|
262
+                   matches[name] = m[name]
263
+                 }
264
+                 true
265
+               else
266
+                 false
267
+               end
268
+            }
269
+          when 'from', 'to', 'cc'
270
+            value.present? or next true
271
+            mail.header[key].addresses.any? { |address|
272
+              Array(value).any? { |pattern|
273
+                glob_match?(pattern, address)
274
+              }
275
+            }
276
+          when 'has_attachment'
277
+            value == mail.has_attachment?
278
+          else
279
+            log 'Unknown condition key ignored: %s' % key
280
+            true
281
+          end
282
+        } or next
283
+
284
+        unless notified.include?(mail.message_id)
285
+          matched_part ||= body_parts.first
286
+
287
+          if matched_part
288
+            mime_type = matched_part.mime_type
289
+            body = matched_part.decoded
290
+          else
291
+            mime_type = 'text/plain'
292
+            body = ''
293
+          end
294
+
295
+          create_event :payload => {
296
+            'folder' => mail.folder,
297
+            'subject' => mail.subject,
298
+            'from' => mail.from_addrs.first,
299
+            'to' => mail.to_addrs,
300
+            'cc' => mail.cc_addrs,
301
+            'date' => (mail.date.iso8601 rescue nil),
302
+            'mime_type' => mime_type,
303
+            'body' => body,
304
+            'matches' => matches,
305
+            'has_attachment' => mail.has_attachment?,
306
+          }
307
+
308
+          notified << mail.message_id if mail.message_id
309
+        end
310
+
311
+        if options['mark_as_read']
312
+          log 'Marking as read'
313
+          mail.mark_as_read
314
+        end
315
+      }
316
+
317
+      notified.slice!(0...-IDCACHE_SIZE) if notified.size > IDCACHE_SIZE
318
+
319
+      memory['seen'] = new_seen
320
+      memory['notified'] = notified
321
+      save!
322
+    end
323
+
324
+    def each_unread_mail
325
+      host, port, ssl, username = options.values_at(:host, :port, :ssl, :username)
326
+
327
+      log "Connecting to #{host}#{':%d' % port if port}#{' via SSL' if ssl}"
328
+      Client.open(host, Integer(port), ssl) { |imap|
329
+        log "Logging in as #{username}"
330
+        imap.login(username, options[:password])
331
+
332
+        options['folders'].each { |folder|
333
+          log "Selecting the folder: %s" % folder
334
+
335
+          imap.select(folder)
336
+
337
+          unseen = imap.search('UNSEEN')
338
+
339
+          if unseen.empty?
340
+            log "No unread mails"
341
+            next
342
+          end
343
+
344
+          imap.fetch_mails(unseen).each { |mail|
345
+            yield mail
346
+          }
347
+        }
348
+      }
349
+    ensure
350
+      log 'Connection closed'
351
+    end
352
+
353
+    def mime_types
354
+      options['mime_types'] || %w[text/plain text/enriched text/html]
355
+    end
356
+
357
+    private
358
+
359
+    def is_positive_integer?(value)
360
+      Integer(value) >= 0
361
+    rescue
362
+      false
363
+    end
364
+
365
+    def glob_match?(pattern, value)
366
+      File.fnmatch?(pattern, value, FNM_FLAGS)
367
+    end
368
+
369
+    class Client < ::Net::IMAP
370
+      class << self
371
+        def open(host, port, ssl)
372
+          imap = new(host, port, ssl)
373
+          yield imap
374
+        ensure
375
+          imap.disconnect unless imap.nil?
376
+        end
377
+      end
378
+
379
+      def select(folder)
380
+        ret = super(@folder = folder)
381
+        @uidvalidity = responses['UIDVALIDITY'].last
382
+        ret
383
+      end
384
+
385
+      def fetch_mails(set)
386
+        fetch(set, %w[UID RFC822.HEADER]).map { |data|
387
+          Message.new(self, data, folder: @folder, uidvalidity: @uidvalidity)
388
+        }
389
+      end
390
+    end
391
+
392
+    class Message < SimpleDelegator
393
+      DEFAULT_BODY_MIME_TYPES = %w[text/plain text/enriched text/html]
394
+
395
+      attr_reader :uid, :folder, :uidvalidity
396
+
397
+      def initialize(client, fetch_data, props = {})
398
+        @client = client
399
+        props.each { |key, value|
400
+          instance_variable_set(:"@#{key}", value)
401
+        }
402
+        attr = fetch_data.attr
403
+        @uid = attr['UID']
404
+        super(Mail.read_from_string(attr['RFC822.HEADER']))
405
+      end
406
+
407
+      def has_attachment?
408
+        @has_attachment ||=
409
+          begin
410
+            data = @client.uid_fetch(@uid, 'BODYSTRUCTURE').first
411
+            struct_has_attachment?(data.attr['BODYSTRUCTURE'])
412
+          end
413
+      end
414
+
415
+      def fetch
416
+        @parsed ||=
417
+          begin
418
+            data = @client.uid_fetch(@uid, 'BODY.PEEK[]').first
419
+            Mail.read_from_string(data.attr['BODY[]'])
420
+          end
421
+      end
422
+
423
+      def body_parts(mime_types = DEFAULT_BODY_MIME_TYPES)
424
+        mail = fetch
425
+        if mail.multipart?
426
+          mail.body.set_sort_order(mime_types)
427
+          mail.body.sort_parts!
428
+          mail.all_parts
429
+        else
430
+          [mail]
431
+        end.reject { |part|
432
+          part.multipart? || part.attachment? || !part.text? ||
433
+            !mime_types.include?(part.mime_type)
434
+        }
435
+      end
436
+
437
+      def mark_as_read
438
+        @client.uid_store(@uid, '+FLAGS', [:Seen])
439
+      end
440
+
441
+      private
442
+
443
+      def struct_has_attachment?(struct)
444
+        struct.multipart? && (
445
+          struct.subtype == 'MIXED' ||
446
+          struct.parts.any? { |part|
447
+            struct_has_attachment?(part)
448
+          }
449
+        )
450
+      end
451
+    end
452
+  end
453
+end

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

@@ -1,5 +1,7 @@
1 1
 module Agents
2 2
   class JabberAgent < Agent
3
+    include LiquidInterpolatable
4
+
3 5
     cannot_be_scheduled!
4 6
     cannot_create_events!
5 7
 
@@ -10,7 +12,9 @@ module Agents
10 12
 
11 13
       The `message` is sent from `jabber_sender` to `jaber_receiver`. This message
12 14
       can contain any keys found in the source's payload, escaped using double curly braces.
13
-      ex: `"News Story: <$.title>: <$.url>"`
15
+      ex: `"News Story: {{title}}: {{url}}"`
16
+
17
+      Have a look at the [Wiki](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) to learn more about liquid templating.
14 18
     MD
15 19
 
16 20
     def default_options
@@ -20,7 +24,7 @@ module Agents
20 24
         'jabber_sender'   => 'huginn@localhost',
21 25
         'jabber_receiver' => 'muninn@localhost',
22 26
         'jabber_password' => '',
23
-        'message'         => 'It will be <$.temp> out tomorrow',
27
+        'message'         => 'It will be {{temp}} out tomorrow',
24 28
         'expected_receive_period_in_days' => "2"
25 29
       }
26 30
     end
@@ -58,7 +62,7 @@ module Agents
58 62
     end
59 63
 
60 64
     def body(event)
61
-      Utils.interpolate_jsonpaths(options['message'], event.payload)
65
+      interpolate_string(options['message'], event.payload)
62 66
     end
63 67
   end
64 68
 end

+ 160 - 0
app/models/agents/jira_agent.rb

@@ -0,0 +1,160 @@
1
+#!/usr/bin/env ruby
2
+
3
+require 'cgi'
4
+require 'httparty'
5
+require 'date'
6
+
7
+module Agents
8
+  class JiraAgent < Agent
9
+    cannot_receive_events!
10
+
11
+    description <<-MD
12
+      The Jira Agent subscribes to Jira issue updates.
13
+
14
+      `jira_url` specifies the full URL of the jira installation, including https://
15
+      `jql` is an optional Jira Query Language-based filter to limit the flow of events. See [JQL Docs](https://confluence.atlassian.com/display/JIRA/Advanced+Searching) for details. 
16
+      `username` and `password` are optional, and may need to be specified if your Jira instance is read-protected
17
+      `timeout` is an optional parameter that specifies how long the request processing may take in minutes.
18
+
19
+      The agent does periodic queries and emits the events containing the updated issues in JSON format.
20
+      NOTE: upon the first execution, the agent will fetch everything available by the JQL query. So if it's not desirable, limit the `jql` query by date.
21
+    MD
22
+
23
+    event_description <<-MD
24
+      Events are the raw JSON generated by Jira REST API
25
+
26
+      {
27
+        "expand": "editmeta,renderedFields,transitions,changelog,operations",
28
+        "id": "80127",
29
+        "self": "https://jira.atlassian.com/rest/api/2/issue/80127",
30
+        "key": "BAM-3512",
31
+        "fields": {
32
+          ...
33
+        }
34
+      }
35
+    MD
36
+
37
+    default_schedule "every_10m"
38
+    MAX_EMPTY_REQUESTS = 10
39
+
40
+    def default_options
41
+      {
42
+        'username'  => '',
43
+        'password' => '',
44
+        'jira_url' => 'https://jira.atlassian.com',
45
+        'jql' => '',
46
+        'expected_update_period_in_days' => '7',
47
+        'timeout' => '1'
48
+      }
49
+    end
50
+
51
+    def validate_options
52
+      errors.add(:base, "you need to specify password if user name is set") if options['username'].present? and not options['password'].present?
53
+      errors.add(:base, "you need to specify your jira URL") unless options['jira_url'].present?
54
+      errors.add(:base, "you need to specify the expected update period") unless options['expected_update_period_in_days'].present?
55
+      errors.add(:base, "you need to specify request timeout") unless options['timeout'].present?
56
+    end
57
+
58
+    def working?
59
+      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
60
+    end
61
+
62
+    def check
63
+      last_run = nil
64
+
65
+      current_run = Time.now.utc.iso8601
66
+      last_run = Time.parse(memory[:last_run]) if memory[:last_run]
67
+      issues = get_issues(last_run)
68
+
69
+      issues.each do |issue|
70
+        updated = Time.parse(issue['fields']['updated'])
71
+
72
+        # this check is more precise than in get_issues()
73
+        # see get_issues() for explanation
74
+        if not last_run or updated > last_run
75
+          create_event :payload => issue
76
+        end
77
+      end
78
+
79
+      memory[:last_run] = current_run
80
+    end
81
+
82
+  private
83
+    def request_url(jql, start_at)
84
+      "#{options[:jira_url]}/rest/api/2/search?jql=#{CGI::escape(jql)}&fields=*all&startAt=#{start_at}"
85
+    end
86
+
87
+    def request_options
88
+      ropts = {:headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)"}}
89
+
90
+      if !options[:username].empty?
91
+        ropts = ropts.merge({:basic_auth => {:username =>options[:username], :password=>options[:password]}})
92
+      end
93
+
94
+      ropts
95
+    end
96
+
97
+    def get(url, options)
98
+        response = HTTParty.get(url, options)
99
+
100
+        if response.code == 400
101
+          raise RuntimeError.new("Jira error: #{response['errorMessages']}") 
102
+        elsif response.code == 403
103
+          raise RuntimeError.new("Authentication failed: Forbidden (403)")
104
+        elsif response.code != 200
105
+          raise RuntimeError.new("Request failed: #{response}")
106
+        end
107
+
108
+        response
109
+    end
110
+
111
+    def get_issues(since)
112
+      startAt = 0
113
+      issues = []
114
+
115
+      # JQL doesn't have an ability to specify timezones
116
+      # Because of this we have to fetch issues 24 h
117
+      # earlier and filter out unnecessary ones at a later
118
+      # stage. Fortunately, the 'updated' field has GMT
119
+      # offset
120
+      since -= 24*60*60 if since
121
+
122
+      jql = ""
123
+
124
+      if !options[:jql].empty? && since
125
+        jql = "(#{options[:jql]}) and updated >= '#{since.strftime('%Y-%m-%d %H:%M')}'"
126
+      else
127
+        jql = options[:jql] if !options[:jql].empty?
128
+        jql = "updated >= '#{since.strftime('%Y-%m-%d %H:%M')}'" if since
129
+      end
130
+
131
+      start_time = Time.now
132
+
133
+      request_limit = 0
134
+      loop do
135
+        response = get(request_url(jql, startAt), request_options)
136
+
137
+        if response['issues'].length == 0
138
+          request_limit+=1
139
+        end
140
+
141
+        if request_limit > MAX_EMPTY_REQUESTS
142
+          raise RuntimeError.new("There is no progress while fetching issues")
143
+        end
144
+
145
+        if Time.now > start_time + options['timeout'].to_i * 60
146
+          raise RuntimeError.new("Timeout exceeded while fetching issues")
147
+        end
148
+
149
+        issues += response['issues']
150
+        startAt += response['issues'].length
151
+ 
152
+        break if startAt >= response['total']
153
+      end
154
+
155
+      issues
156
+    end
157
+
158
+  end
159
+end
160
+

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

@@ -2,10 +2,12 @@ require 'pp'
2 2
 
3 3
 module Agents
4 4
   class PeakDetectorAgent < Agent
5
+    include LiquidInterpolatable
6
+
5 7
     cannot_be_scheduled!
6 8
 
7 9
     description <<-MD
8
-      Use a PeakDetectorAgent to watch for peaks in an event stream.  When a peak is detected, the resulting Event will have a payload message of `message`.  You can include extractions in the message, for example: `I saw a bar of: <foo.bar>`
10
+      Use a PeakDetectorAgent to watch for peaks in an event stream.  When a peak is detected, the resulting Event will have a payload message of `message`.  You can include extractions in the message, for example: `I saw a bar of: {{foo.bar}}`, have a look at the [Wiki](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) for details.
9 11
 
10 12
       The `value_path` value is a [JSONPaths](http://goessner.net/articles/JsonPath/) to the value of interest.  `group_by_path` is a hash path that will be used to group values, if present.
11 13
 
@@ -67,7 +69,7 @@ module Agents
67 69
         if newest_value > average_value + std_multiple * standard_deviation
68 70
           memory['peaks'][group] << newest_time
69 71
           memory['peaks'][group].reject! { |p| p <= newest_time - window_duration }
70
-          create_event :payload => { 'message' => options['message'], 'peak' => newest_value, 'peak_time' => newest_time, 'grouped_by' => group.to_s }
72
+          create_event :payload => { 'message' => interpolate_string(options['message'], event.payload), 'peak' => newest_value, 'peak_time' => newest_time, 'grouped_by' => group.to_s }
71 73
         end
72 74
       end
73 75
     end

+ 8 - 15
app/models/agents/pushbullet_agent.rb

@@ -1,6 +1,6 @@
1 1
 module Agents
2 2
   class PushbulletAgent < Agent
3
-    include JsonPathOptionsOverwritable
3
+    include LiquidInterpolatable
4 4
 
5 5
     cannot_be_scheduled!
6 6
     cannot_create_events!
@@ -20,7 +20,7 @@ module Agents
20 20
 
21 21
       You can provide a `title` and a `body`.
22 22
 
23
-      If you want to specify `title` or `body` per event, you can provide a [JSONPath](http://goessner.net/articles/JsonPath/) for each of them.
23
+      In every value of the options hash you can use the liquid templating, learn more about it at the [Wiki](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid).
24 24
     MD
25 25
 
26 26
     def default_options
@@ -28,9 +28,7 @@ module Agents
28 28
         'api_key' => '',
29 29
         'device_id' => '',
30 30
         'title' => "Hello from Huginn!",
31
-        'title_path' => '',
32
-        'body' => '',
33
-        'body_path' => '',
31
+        'body' => '{{body}}',
34 32
       }
35 33
     end
36 34
 
@@ -52,16 +50,11 @@ module Agents
52 50
 
53 51
     private
54 52
     def query_options(event)
55
-      mo = merge_json_path_options event
56
-      basic_options.deep_merge(:body => {:title => mo[:title], :body => mo[:body]})
57
-    end
58
-
59
-    def basic_options
60
-      {:basic_auth => {:username =>options[:api_key], :password=>''}, :body => {:device_iden => options[:device_id], :type => 'note'}}
61
-    end
62
-
63
-    def options_with_path
64
-      [:title, :body]
53
+      mo = interpolate_options options, event.payload
54
+      {
55
+        :basic_auth => {:username =>mo[:api_key], :password=>''},
56
+        :body => {:device_iden => mo[:device_id], :title => mo[:title], :body => mo[:body], :type => 'note'}
57
+      }
65 58
     end
66 59
   end
67 60
 end

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

@@ -1,5 +1,6 @@
1 1
 module Agents
2 2
   class TranslationAgent < Agent
3
+    include LiquidInterpolatable
3 4
 
4 5
     cannot_be_scheduled!
5 6
 
@@ -8,7 +9,7 @@ module Agents
8 9
       Services are provided using Microsoft Translator. You can [sign up](https://datamarket.azure.com/dataset/bing/microsofttranslator) and [register your application](https://datamarket.azure.com/developer/applications/register) to get `client_id` and `client_secret` which are required to use this agent.
9 10
       `to` must be filled with a [translator language code](http://msdn.microsoft.com/en-us/library/hh456380.aspx).
10 11
 
11
-      Specify what you would like to translate in `content` field, by specifying key and JSONPath of content to be translated.
12
+      Specify what you would like to translate in `content` field, you can use [Liquid](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) specify which part of the payload you want to translate.
12 13
 
13 14
       `expected_receive_period_in_days` is the maximum number of days you would allow to pass between events.
14 15
     MD
@@ -22,8 +23,8 @@ module Agents
22 23
         'to' => "fi",
23 24
         'expected_receive_period_in_days' => 1,
24 25
         'content' => {
25
-          'text' => "$.message.text",
26
-          'content' => "$.xyz"
26
+          'text' => "{{message.text}}",
27
+          'content' => "{{xyz}}"
27 28
         }
28 29
       }
29 30
     end
@@ -68,8 +69,8 @@ module Agents
68 69
       incoming_events.each do |event|
69 70
         translated_event = {}
70 71
         options['content'].each_pair do |key, value|
71
-          to_be_translated = Utils.values_at event.payload, value
72
-          translated_event[key] = translate to_be_translated.first, options['to'], access_token
72
+          to_be_translated = interpolate_string(value, event.payload)
73
+          translated_event[key] = translate(to_be_translated.first, options['to'], access_token)
73 74
         end
74 75
         create_event :payload => translated_event
75 76
       end

+ 6 - 4
app/models/agents/trigger_agent.rb

@@ -1,5 +1,7 @@
1 1
 module Agents
2 2
   class TriggerAgent < Agent
3
+    include LiquidInterpolatable
4
+
3 5
     cannot_be_scheduled!
4 6
 
5 7
     VALID_COMPARISON_TYPES = %w[regex !regex field<value field<=value field==value field!=value field>=value field>value]
@@ -13,7 +15,7 @@ module Agents
13 15
 
14 16
       The `value` can be a single value or an array of values. In the case of an array, if one or more values match then the rule matches. 
15 17
 
16
-      All rules must match for the Agent to match.  The resulting Event will have a payload message of `message`.  You can include extractions in the message, for example: `I saw a bar of: <foo.bar>`
18
+      All rules must match for the Agent to match.  The resulting Event will have a payload message of `message`.  You can use liquid templating in the `message, have a look at the [Wiki](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) for details.
17 19
 
18 20
       Set `keep_event` to `true` if you'd like to re-emit the incoming event, optionally merged with 'message' when provided.
19 21
 
@@ -46,7 +48,7 @@ module Agents
46 48
                       'value' => "foo\\d+bar",
47 49
                       'path' => "topkey.subkey.subkey.goal",
48 50
                     }],
49
-        'message' => "Looks like your pattern matched in '<value>'!"
51
+        'message' => "Looks like your pattern matched in '{{value}}'!"
50 52
       }
51 53
     end
52 54
 
@@ -88,9 +90,9 @@ module Agents
88 90
         if match
89 91
           if keep_event?
90 92
             payload = event.payload.dup
91
-            payload['message'] = make_message(event[:payload]) if options['message'].present?
93
+            payload['message'] = interpolate_string(options['message'], event.payload) if options['message'].present?
92 94
           else
93
-            payload = { 'message' => make_message(event[:payload]) }
95
+            payload = { 'message' => interpolate_string(options['message'], event.payload) }
94 96
           end
95 97
 
96 98
           create_event :payload => payload

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

@@ -3,6 +3,7 @@ require "twitter"
3 3
 module Agents
4 4
   class TwitterPublishAgent < Agent
5 5
     include TwitterConcern
6
+    include LiquidInterpolatable
6 7
 
7 8
     cannot_be_scheduled!
8 9
 
@@ -15,7 +16,7 @@ module Agents
15 16
 
16 17
       To get oAuth credentials for Twitter, [follow these instructions](https://github.com/cantino/huginn/wiki/Getting-a-twitter-oauth-token).
17 18
 
18
-      You must also specify a `message_path` parameter: a [JSONPaths](http://goessner.net/articles/JsonPath/) to the value to tweet.
19
+      You must also specify a `message` parameter, you can use [Liquid](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) to format the message.
19 20
 
20 21
       Set `expected_update_period_in_days` to the maximum amount of time that you'd expect to pass between Events being created by this Agent.
21 22
     MD
@@ -31,7 +32,7 @@ module Agents
31 32
     def default_options
32 33
       {
33 34
         'expected_update_period_in_days' => "10",
34
-        'message_path' => "text"
35
+        'message' => "{{text}}"
35 36
       }
36 37
     end
37 38
 
@@ -41,7 +42,7 @@ module Agents
41 42
         incoming_events = incoming_events.first(20)
42 43
       end
43 44
       incoming_events.each do |event|
44
-        tweet_text = Utils.value_at(event.payload, options['message_path'])
45
+        tweet_text = interpolate_string(options['message'], event.payload)
45 46
         begin
46 47
           tweet = publish_tweet tweet_text
47 48
           create_event :payload => {

+ 41 - 0
app/views/agents/_action_menu.html.erb

@@ -0,0 +1,41 @@
1
+<ul class="dropdown-menu" role="menu">
2
+  <% if agent.can_be_scheduled? %>
3
+    <li>
4
+      <%= link_to '<span class="color-success glyphicon glyphicon-refresh"></span> Run'.html_safe, run_agent_path(agent, :return => returnTo), method: :post, :tabindex => "-1" %>
5
+    </li>
6
+  <% end %>
7
+
8
+  <li>
9
+    <%= link_to '<span class="glyphicon glyphicon-eye-open"></span> Show'.html_safe, agent_path(agent) %>
10
+  </li>
11
+
12
+  <li class="divider"></li>
13
+
14
+  <li>
15
+    <%= link_to '<span class="glyphicon glyphicon-pencil"></span> Edit agent'.html_safe, edit_agent_path(agent) %>
16
+  </li>
17
+
18
+  <li>
19
+    <%= link_to '<span class="glyphicon glyphicon-plus"></span> Clone agent'.html_safe, new_agent_path(id: agent), :tabindex => "-1" %>
20
+  </li>
21
+
22
+  <li>
23
+    <% if agent.disabled? %>
24
+      <%= link_to '<i class="glyphicon glyphicon-play"></i> Enable agent'.html_safe, agent_path(agent, :agent => { :disabled => false }, :return => returnTo), :method => :put %>
25
+    <% else %>
26
+      <%= link_to '<i class="glyphicon glyphicon-pause"></i> Disable agent'.html_safe, agent_path(agent, :agent => { :disabled => true }, :return => returnTo), :method => :put %>
27
+    <% end %>
28
+  </li>
29
+
30
+  <li class="divider"></li>
31
+
32
+  <% if agent.can_create_events? && agent.events.count > 0 %>
33
+    <li>
34
+      <%= 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" %>
35
+    </li>
36
+  <% end %>
37
+
38
+  <li>
39
+    <%= 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" %>
40
+  </li>
41
+</ul>

+ 13 - 17
app/views/agents/index.html.erb

@@ -19,41 +19,41 @@
19 19
           </tr>
20 20
 
21 21
           <% @agents.each do |agent| %>
22
-            <tr class='<%= "agent-disabled" if agent.disabled? %>'>
23
-              <td>
24
-                <%= agent.name %>
22
+            <tr>
23
+              <td class='<%= "agent-disabled" if agent.disabled? %>'>
24
+                <%= link_to agent.name, agent_path(agent) %>
25 25
                 <br/>
26 26
                 <span class='text-muted'><%= agent.short_type.titleize %></span>
27 27
               </td>
28
-              <td>
28
+              <td class='<%= "agent-disabled" if agent.disabled? %>'>
29 29
                 <% if agent.can_be_scheduled? %>
30 30
                   <%= agent.schedule.to_s.humanize.titleize %>
31 31
                 <% else %>
32 32
                   <span class='not-applicable'></span>
33 33
                 <% end %>
34 34
               </td>
35
-              <td>
35
+              <td class='<%= "agent-disabled" if agent.disabled? %>'>
36 36
                 <% if agent.can_be_scheduled? %>
37 37
                   <%= agent.last_check_at ? time_ago_in_words(agent.last_check_at) + " ago" : "never" %>
38 38
                 <% else %>
39 39
                   <span class='not-applicable'></span>
40 40
                 <% end %>
41 41
               </td>
42
-              <td>
42
+              <td class='<%= "agent-disabled" if agent.disabled? %>'>
43 43
                 <% if agent.can_create_events? %>
44 44
                   <%= agent.last_event_at ? time_ago_in_words(agent.last_event_at) + " ago" : "never" %>
45 45
                 <% else %>
46 46
                   <span class='not-applicable'></span>
47 47
                 <% end %>
48 48
               </td>
49
-              <td>
49
+              <td class='<%= "agent-disabled" if agent.disabled? %>'>
50 50
                 <% if agent.can_receive_events? %>
51 51
                   <%= agent.last_receive_at ? time_ago_in_words(agent.last_receive_at) + " ago" : "never" %>
52 52
                 <% else %>
53 53
                   <span class='not-applicable'></span>
54 54
                 <% end %>
55 55
               </td>
56
-              <td>
56
+              <td class='<%= "agent-disabled" if agent.disabled? %>'>
57 57
                 <% if agent.can_create_events? %>
58 58
                   <%= link_to(agent.events_count || 0, events_path(:agent => agent.to_param)) %>
59 59
                 <% else %>
@@ -62,15 +62,11 @@
62 62
               </td>
63 63
               <td><%= working(agent) %></td>
64 64
               <td>
65
-                <div class="btn-group btn-group-xs">
66
-                  <%= link_to 'Show', agent_path(agent), class: "btn btn-default" %>
67
-                  <%= link_to 'Edit', edit_agent_path(agent), class: "btn btn-default" %>
68
-                  <%= link_to 'Delete', agent_path(agent), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default" %>
69
-                  <% if agent.can_be_scheduled? && !agent.disabled? %>
70
-                    <%= link_to 'Run', run_agent_path(agent, :return => "index"), method: :post, class: "btn btn-default" %>
71
-                  <% else %>
72
-                    <%= link_to 'Run', "#", class: "btn btn-default disabled" %>
73
-                  <% end %>
65
+                <div class="btn-group">
66
+                  <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
67
+                    <span class="glyphicon glyphicon-th-list"></span> Actions <span class="caret"></span>
68
+                  </button>
69
+                  <%= render 'action_menu', :agent => agent, :returnTo => "index" %>
74 70
                 </div>
75 71
               </td>
76 72
             </tr>

+ 7 - 32
app/views/agents/show.html.erb

@@ -2,6 +2,8 @@
2 2
   <div class='row'>
3 3
     <div class='col-md-2'>
4 4
         <ul class="nav nav-pills nav-stacked" id="show-tabs">
5
+          <li><%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, agents_path %></li>
6
+
5 7
           <% if agent_show_view(@agent).present? %>
6 8
             <li class='active'><a href="#summary" data-toggle="tab"><span class='glyphicon glyphicon-picture'></span> Summary</a></li>
7 9
             <li><a href="#details" data-toggle="tab"><span class='glyphicon glyphicon-indent-left'></span> Details</a></li>
@@ -9,45 +11,18 @@
9 11
             <li class='disabled'><a><span class='glyphicon glyphicon-picture'></span> Summary</a></li>
10 12
             <li class='active'><a href="#details" data-toggle="tab"><span class='glyphicon glyphicon-indent-left'></span> Details</a></li>
11 13
           <% end %>
14
+
12 15
           <li><a href="#logs" data-toggle="tab" data-agent-id="<%= @agent.id %>" class='<%= @agent.recent_error_logs? ? 'recent-errors' : '' %>'><span class='glyphicon glyphicon-list-alt'></span> Logs</a></li>
13 16
 
14 17
           <% if @agent.can_create_events? && @agent.events.count > 0 %>
15 18
             <li><%= link_to '<span class="glyphicon glyphicon-random"></span> Events'.html_safe, events_path(:agent => @agent.to_param) %></li>
19
+          <% else %>
20
+            <li class='disabled'><a><span class='glyphicon glyphicon-random'></span> Events</a></li>
16 21
           <% end %>
17
-          <li><%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, agents_path %></li>
18
-          <li><%= link_to '<span class="glyphicon glyphicon-pencil"></span> Edit'.html_safe, edit_agent_path(@agent) %></li>
19 22
 
20 23
           <li class="dropdown">
21
-            <a class="dropdown-toggle" data-toggle="dropdown" href="#">Actions <b class="caret"></b></a>
22
-            <ul class="dropdown-menu" role="menu" aria-labelledby="dLabel">
23
-              <% if @agent.can_be_scheduled? %>
24
-                <li>
25
-                  <%= link_to '<span class="glyphicon glyphicon-refresh"></span> Run'.html_safe, run_agent_path(@agent, :return => "show"), method: :post, :tabindex => "-1" %>
26
-                </li>
27
-              <% end %>
28
-
29
-              <% if @agent.can_create_events? && @agent.events.count > 0 %>
30
-                <li>
31
-                  <%= link_to '<span class="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" %>
32
-                </li>
33
-              <% end %>
34
-
35
-              <li>
36
-                <%= link_to '<span class="glyphicon glyphicon-plus"></span> Clone'.html_safe, new_agent_path(id: @agent), :tabindex => "-1" %>
37
-              </li>
38
-
39
-              <li>
40
-                <% if @agent.disabled? %>
41
-                  <%= link_to '<i class="glyphicon glyphicon-play"></i> Enable agent'.html_safe, agent_path(@agent, :agent => { :disabled => false }, :return => "show"), :method => :put %>
42
-                <% else %>
43
-                  <%= link_to '<i class="glyphicon glyphicon-pause"></i> Disable agent'.html_safe, agent_path(@agent, :agent => { :disabled => true }, :return => "show"), :method => :put %>
44
-                <% end %>
45
-              </li>
46
-
47
-              <li>
48
-                <%= link_to '<span class="glyphicon glyphicon-remove"></span> Delete'.html_safe, agent_path(@agent), method: :delete, data: { confirm: 'Are you sure?' }, :tabindex => "-1" %>
49
-              </li>
50
-            </ul>
24
+            <a class="dropdown-toggle" data-toggle="dropdown" href="#"><span class="glyphicon glyphicon-th-list"></span> Actions <span class="caret"></span></a>
25
+            <%= render 'action_menu', :agent => @agent, :returnTo => "show" %>
51 26
           </li>
52 27
         </ul>
53 28
       </div>

+ 1 - 1
app/views/layouts/_messages.html.erb

@@ -1,7 +1,7 @@
1 1
 <% if flash.keys.length > 0 %>
2 2
   <div class="flash">
3 3
     <% flash.each do |name, msg| %>
4
-      <div class="alert alert-<%= name.to_sym == :notice ? "success" : "error" %> alert-dismissable">
4
+      <div class="alert alert-<%= name.to_sym == :notice ? "success" : "danger" %> alert-dismissable">
5 5
         <button type="button" class="close" data-dismiss="alert" aria-hidden="true">&times;</button>
6 6
         <%= content_tag :div, msg, :id => "flash_#{name}" if msg.is_a?(String) %>
7 7
       </div>

+ 45 - 0
db/migrate/20140505201716_migrate_agents_to_liquid_templating.rb

@@ -0,0 +1,45 @@
1
+class MigrateAgentsToLiquidTemplating < ActiveRecord::Migration
2
+  def up
3
+    Agent.where(:type => 'Agents::HipchatAgent').each do |agent|
4
+      LiquidMigrator.convert_all_agent_options(agent)
5
+    end
6
+    Agent.where(:type => 'Agents::EventFormattingAgent').each do |agent|
7
+      agent.options['instructions'] = LiquidMigrator.convert_hash(agent.options['instructions'], {:merge_path_attributes => true, :leading_dollarsign_is_jsonpath => true})
8
+      agent.save
9
+    end
10
+    Agent.where(:type => 'Agents::PushbulletAgent').each do |agent|
11
+      LiquidMigrator.convert_all_agent_options(agent)
12
+    end
13
+    Agent.where(:type => 'Agents::JabberAgent').each do |agent|
14
+      LiquidMigrator.convert_all_agent_options(agent)
15
+    end
16
+    Agent.where(:type => 'Agents::DataOutputAgent').each do |agent|
17
+      LiquidMigrator.convert_all_agent_options(agent)
18
+    end
19
+    Agent.where(:type => 'Agents::TranslationAgent').each do |agent|
20
+      agent.options['content'] = LiquidMigrator.convert_hash(agent.options['content'], {:merge_path_attributes => true, :leading_dollarsign_is_jsonpath => true})
21
+      agent.save
22
+    end
23
+    Agent.where(:type => 'Agents::TwitterPublishAgent').each do |agent|
24
+      if (message = agent.options.delete('message_path')).present?
25
+        agent.options['message'] = "{{#{message}}}"
26
+        agent.save
27
+      end
28
+    end
29
+    Agent.where(:type => 'Agents::TriggerAgent').each do |agent|
30
+      agent.options['message'] = LiquidMigrator.convert_make_message(agent.options['message'])
31
+      agent.save
32
+    end
33
+    Agent.where(:type => 'Agents::PeakDetectorAgent').each do |agent|
34
+      agent.options['message'] = LiquidMigrator.convert_make_message(agent.options['message'])
35
+      agent.save
36
+    end
37
+    Agent.where(:type => 'Agents::HumanTaskAgent').each do |agent|
38
+      LiquidMigrator.convert_all_agent_options(agent)
39
+    end
40
+  end
41
+
42
+  def down
43
+    raise ActiveRecord::IrreversibleMigration, "Cannot revert migration to Liquid templating"
44
+  end
45
+end

+ 11 - 11
deployment/Cheffile.lock

@@ -1,19 +1,19 @@
1 1
 SITE
2 2
   remote: http://community.opscode.com/api/v1
3 3
   specs:
4
-    apt (2.3.8)
4
+    apt (2.4.0)
5 5
     bluepill (2.3.1)
6 6
       rsyslog (>= 0.0.0)
7
-    build-essential (2.0.0)
7
+    build-essential (2.0.2)
8 8
     chef_handler (1.1.6)
9 9
     dmg (2.2.0)
10
-    ohai (1.1.12)
10
+    ohai (2.0.0)
11 11
     rsyslog (1.12.2)
12 12
     runit (1.5.10)
13 13
       build-essential (>= 0.0.0)
14 14
       yum (~> 3.0)
15 15
       yum-epel (>= 0.0.0)
16
-    windows (1.30.2)
16
+    windows (1.31.0)
17 17
       chef_handler (>= 0.0.0)
18 18
     yum (3.2.0)
19 19
     yum-epel (0.3.6)
@@ -32,9 +32,9 @@ GIT
32 32
 GIT
33 33
   remote: git://github.com/opscode-cookbooks/git.git
34 34
   ref: master
35
-  sha: 76b0f9bb08fdd9e2e201fd70b72298097accdf96
35
+  sha: b1bca76aaf3a3a2131744f17f6e5087e22fa3c40
36 36
   specs:
37
-    git (4.0.1)
37
+    git (4.0.3)
38 38
       build-essential (>= 0.0.0)
39 39
       dmg (>= 0.0.0)
40 40
       runit (>= 1.0)
@@ -45,20 +45,20 @@ GIT
45 45
 GIT
46 46
   remote: git://github.com/opscode-cookbooks/mysql.git
47 47
   ref: master
48
-  sha: a2ff53f0ca6deca75aebf6da55ac381194ec7728
48
+  sha: 4b70e99730ab4a7ce6c1dd7a35654a764fb6e0fe
49 49
   specs:
50
-    mysql (5.1.9)
50
+    mysql (5.2.13)
51 51
 
52 52
 GIT
53 53
   remote: git://github.com/opscode-cookbooks/nginx.git
54 54
   ref: master
55
-  sha: 05b3a613f53a0b05c96f9206c5d67aa420f337fb
55
+  sha: 45588ee2a5c7144a0ef2a5992e7f273542236d27
56 56
   specs:
57
-    nginx (2.6.3)
57
+    nginx (2.7.3)
58 58
       apt (~> 2.2)
59 59
       bluepill (~> 2.3)
60 60
       build-essential (~> 2.0)
61
-      ohai (~> 1.1)
61
+      ohai (~> 2.0)
62 62
       runit (~> 1.2)
63 63
       yum-epel (~> 0.3)
64 64
 

+ 11 - 4
deployment/site-cookbooks/huginn_development/recipes/default.rb

@@ -16,16 +16,23 @@ group "huginn" do
16 16
   action :create
17 17
 end
18 18
 
19
-%w("ruby1.9.1" "ruby1.9.1-dev" "libxslt-dev" "libxml2-dev" "curl" "libmysqlclient-dev" "rubygems").each do |pkg|
19
+%w("ruby1.9.1" "ruby1.9.1-dev" "libxslt-dev" "libxml2-dev" "curl" "libmysqlclient-dev" "libffi-dev" "libssl-dev").each do |pkg|
20 20
   package pkg do
21 21
     action :install
22 22
   end
23 23
 end
24 24
 
25
-bash "Setting default ruby version to 1.9" do
25
+bash "Setting default ruby and gem versions to 1.9" do
26 26
   code <<-EOH
27
-    update-alternatives --set ruby /usr/bin/ruby1.9.1
28
-    update-alternatives --set gem /usr/bin/gem1.9.1
27
+    if [ $(readlink /usr/bin/ruby) != "ruby1.9.1" ]
28
+    then
29
+      update-alternatives --set ruby /usr/bin/ruby1.9.1
30
+    fi
31
+
32
+    if [ $(readlink /usr/bin/gem) != "gem1.9.1" ]
33
+    then
34
+      update-alternatives --set gem /usr/bin/gem1.9.1
35
+    fi
29 36
   EOH
30 37
 end
31 38
 

+ 11 - 4
deployment/site-cookbooks/huginn_production/recipes/default.rb

@@ -14,14 +14,21 @@ group "huginn" do
14 14
   members ["huginn"]
15 15
 end
16 16
 
17
-%w("ruby1.9.1" "ruby1.9.1-dev" "libxslt-dev" "libxml2-dev" "curl" "libshadow-ruby1.8" "libmysqlclient-dev" "libffi-dev" "libssl-dev" "rubygems").each do |pkg|
17
+%w("ruby1.9.1" "ruby1.9.1-dev" "libxslt-dev" "libxml2-dev" "curl" "libmysqlclient-dev" "libffi-dev" "libssl-dev").each do |pkg|
18 18
   package("#{pkg}")
19 19
 end
20 20
 
21
-bash "Setting default ruby version to 1.9" do
21
+bash "Setting default ruby and gem versions to 1.9" do
22 22
   code <<-EOH
23
-    update-alternatives --set ruby /usr/bin/ruby1.9.1
24
-    update-alternatives --set gem /usr/bin/gem1.9.1
23
+    if [ $(readlink /usr/bin/ruby) != "ruby1.9.1" ]
24
+    then
25
+      update-alternatives --set ruby /usr/bin/ruby1.9.1
26
+    fi
27
+
28
+    if [ $(readlink /usr/bin/gem) != "gem1.9.1" ]
29
+    then
30
+      update-alternatives --set gem /usr/bin/gem1.9.1
31
+    fi
25 32
   EOH
26 33
 end
27 34
 

+ 78 - 0
lib/liquid_migrator.rb

@@ -0,0 +1,78 @@
1
+module LiquidMigrator
2
+  def self.convert_all_agent_options(agent)
3
+    agent.options = self.convert_hash(agent.options, {:merge_path_attributes => true, :leading_dollarsign_is_jsonpath => true})
4
+    agent.save!
5
+  end
6
+
7
+  def self.convert_hash(hash, options={})
8
+    options = {:merge_path_attributes => false, :leading_dollarsign_is_jsonpath => false}.merge options
9
+    keys_to_remove = []
10
+    hash.tap do |hash|
11
+      hash.each_pair do |key, value|
12
+        case value.class.to_s
13
+        when 'String', 'FalseClass', 'TrueClass'
14
+          path_key = "#{key}_path"
15
+          if options[:merge_path_attributes] && !hash[path_key].nil?
16
+            # replace the value if the path is present
17
+            value = hash[path_key] if hash[path_key].present?
18
+            # in any case delete the path attibute
19
+            keys_to_remove << path_key
20
+          end
21
+          hash[key] = LiquidMigrator.convert_string value, options[:leading_dollarsign_is_jsonpath]
22
+        when 'ActiveSupport::HashWithIndifferentAccess'
23
+          hash[key] = convert_hash(hash[key], options)
24
+        when 'Array'
25
+          hash[key] = hash[key].collect { |k|
26
+            if k.class == String
27
+              convert_string(k, options[:leading_dollarsign_is_jsonpath])
28
+            else
29
+              convert_hash(k, options)
30
+            end
31
+          }
32
+        end
33
+      end
34
+        # remove the unneeded *_path attributes
35
+    end.select { |k, v| !keys_to_remove.include? k }
36
+  end
37
+
38
+  def self.convert_string(string, leading_dollarsign_is_jsonpath=false)
39
+    if string == true || string == false
40
+      # there might be empty *_path attributes for boolean defaults
41
+      string
42
+    elsif string[0] == '$' && leading_dollarsign_is_jsonpath
43
+      # in most cases a *_path attribute
44
+      convert_json_path string
45
+    else
46
+      # migrate the old interpolation syntax to the new liquid based
47
+      string.gsub(/<([^>]+)>/).each do
48
+        match = $1
49
+        if match =~ /\Aescape /
50
+          # convert the old escape syntax to a liquid filter
51
+          self.convert_json_path(match.gsub(/\Aescape /, '').strip, ' | uri_escape')
52
+        else
53
+          self.convert_json_path(match.strip)
54
+        end
55
+      end
56
+    end
57
+  end
58
+
59
+  def self.convert_make_message(string)
60
+    string.gsub(/<([^>]+)>/, "{{\\1}}")
61
+  end
62
+
63
+  def self.convert_json_path(string, filter = "")
64
+    check_path(string)
65
+    if string.start_with? '$.'
66
+      "{{#{string[2..-1]}#{filter}}}"
67
+    else
68
+      "{{#{string[1..-1]}#{filter}}}"
69
+    end
70
+  end
71
+
72
+  def self.check_path(string)
73
+    if string !~ /\A(\$\.?)?(\w+\.)*(\w+)\Z/
74
+      raise "JSONPath '#{string}' is too complex, please check your migration."
75
+    end
76
+  end
77
+end
78
+

+ 22 - 0
spec/data_fixtures/imap1.eml

@@ -0,0 +1,22 @@
1
+From: Nanashi <nanashi.gombeh@example.jp>
2
+Date: Fri, 9 May 2014 16:00:00 +0900
3
+Message-ID: <foo.123@mail.example.jp>
4
+Subject: some subject
5
+To: Jane <jane.doe@example.com>, John <john.doe@example.com>
6
+MIME-Version: 1.0
7
+Content-Type: multipart/alternative; boundary=d8c92622e09101e4bc833685557b
8
+
9
+--d8c92622e09101e4bc833685557b
10
+Content-Type: text/plain; charset=UTF-8
11
+
12
+Some plain text
13
+Some second line
14
+
15
+--d8c92622e09101e4bc833685557b
16
+Content-Type: text/html; charset=UTF-8
17
+Content-Transfer-Encoding: quoted-printable
18
+
19
+<div dir=3D"ltr">Some HTML document<br>
20
+Some second line of HTML<br></div>
21
+
22
+--d8c92622e09101e4bc833685557b--

+ 20 - 0
spec/data_fixtures/imap2.eml

@@ -0,0 +1,20 @@
1
+From: John <john.doe@example.com>
2
+Date: Fri, 9 May 2014 17:00:00 +0900
3
+Message-ID: <bar.456@mail.example.com>
4
+Subject: Re: some subject
5
+To: Jane <jane.doe@example.com>, Nanashi <nanashi.gombeh@example.jp>
6
+MIME-Version: 1.0
7
+Content-Type: multipart/alternative; boundary=d8c92622e09101e4bc833685557b
8
+
9
+--d8c92622e09101e4bc833685557b
10
+Content-Type: text/plain; charset=UTF-8
11
+
12
+Some reply
13
+
14
+--d8c92622e09101e4bc833685557b
15
+Content-Type: text/html; charset=UTF-8
16
+Content-Transfer-Encoding: quoted-printable
17
+
18
+<div dir=3D"ltr">Some HTML reply<br></div>
19
+
20
+--d8c92622e09101e4bc833685557b--

+ 0 - 0
spec/data_fixtures/jira.json

@@ -0,0 +1 @@