@@ -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' |
@@ -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) |
@@ -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 |
@@ -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 |
+} |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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." |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
+ |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 => { |
@@ -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> |
@@ -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> |
@@ -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,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">×</button> |
6 | 6 |
<%= content_tag :div, msg, :id => "flash_#{name}" if msg.is_a?(String) %> |
7 | 7 |
</div> |
@@ -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 |
@@ -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 |
|
@@ -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 |
|
@@ -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 |
|
@@ -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 |
+ |
@@ -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-- |
@@ -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 +1 @@ |