herald 0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +5 -0
- data/Gemfile +4 -0
- data/README.md +201 -0
- data/Rakefile +9 -0
- data/TODO.txt +12 -0
- data/herald.gemspec +24 -0
- data/lib/herald.rb +178 -0
- data/lib/herald/item.rb +13 -0
- data/lib/herald/notifier.rb +33 -0
- data/lib/herald/notifiers/callback.rb +24 -0
- data/lib/herald/notifiers/growl.rb +37 -0
- data/lib/herald/notifiers/ping.rb +46 -0
- data/lib/herald/notifiers/post.rb +47 -0
- data/lib/herald/notifiers/stdout.rb +27 -0
- data/lib/herald/version.rb +3 -0
- data/lib/herald/watcher.rb +99 -0
- data/lib/herald/watchers/imap.rb +55 -0
- data/lib/herald/watchers/rss.rb +64 -0
- data/lib/herald/watchers/twitter.rb +54 -0
- data/test/herald_test.rb +110 -0
- metadata +115 -0
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,201 @@
|
|
1
|
+
Herald
|
2
|
+
====
|
3
|
+
|
4
|
+
Herald is a simple notifier for Twitter, RSS, or email.
|
5
|
+
|
6
|
+
Pass Herald some keywords and sources to watch, and Herald will notify you using Growl, email, pinging a site, or running Ruby code as soon as those keywords appear.
|
7
|
+
|
8
|
+
Installation
|
9
|
+
------------
|
10
|
+
|
11
|
+
### Rubygems
|
12
|
+
|
13
|
+
gem install herald
|
14
|
+
|
15
|
+
### GitHub
|
16
|
+
|
17
|
+
git clone git://github.com/lukes/herald.git
|
18
|
+
gem build herald.gemspec
|
19
|
+
gem install herald-<version>.gem
|
20
|
+
|
21
|
+
Usage
|
22
|
+
-----
|
23
|
+
|
24
|
+
First step is `require` Herald into your Ruby project:
|
25
|
+
|
26
|
+
require 'rubygems'
|
27
|
+
require 'herald'
|
28
|
+
|
29
|
+
Then, to watch for tweets containing "soundofmusic":
|
30
|
+
|
31
|
+
Herald.watch do
|
32
|
+
check :twitter
|
33
|
+
_for "soundofmusic"
|
34
|
+
end
|
35
|
+
|
36
|
+
Or an RSS feed:
|
37
|
+
|
38
|
+
Herald.watch do
|
39
|
+
check :rss, :from => "http://example.com/.rss"
|
40
|
+
_for "soundofmusic"
|
41
|
+
end
|
42
|
+
|
43
|
+
Or an email inbox: [Not Implemented]
|
44
|
+
|
45
|
+
Herald.watch do
|
46
|
+
check :inbox, :imap => "imap.server.com", :user => "username", :pass => "supersecret"
|
47
|
+
_for "soundofmusic"
|
48
|
+
end
|
49
|
+
|
50
|
+
### Watching multiple sources, or for multiple keywords
|
51
|
+
|
52
|
+
Watching two RSS feeds and Twitter for two keywords
|
53
|
+
|
54
|
+
Herald.watch do
|
55
|
+
check :rss, :from => ["http://earthquakes.gov/.rss", "http://livenews.com/.rss"]
|
56
|
+
check :twitter
|
57
|
+
_for "christchurch", "earthquake"
|
58
|
+
end
|
59
|
+
|
60
|
+
Or, if sources should have different keywords
|
61
|
+
|
62
|
+
Herald.watch do
|
63
|
+
check :rss, :from => "http://earthquake.usgs.gov/earthquakes/catalogs/eqs1day-M0.xml"
|
64
|
+
_for "christchurch"
|
65
|
+
end
|
66
|
+
check :twitter do
|
67
|
+
_for "#eqnz", "#chch", "#quake"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
### Actions
|
72
|
+
|
73
|
+
By default Herald will use Ruby's `$stdout` and simply prints what it finds.
|
74
|
+
|
75
|
+
Swap in another action by passing Herald one of the following `action` parameters:
|
76
|
+
|
77
|
+
#### Growl
|
78
|
+
|
79
|
+
[Growl](http://growl.info/) is a notification system for Mac OS X.
|
80
|
+
|
81
|
+
To use Growl, enable "Listen for incoming notifications" and "Allow remote application registration" on the [Network tab](http://growl.info/documentation/exploring-preferences.php) of the Growl Preference Panel, and pass Herald `action :growl`:
|
82
|
+
|
83
|
+
Herald.watch_twitter do
|
84
|
+
_for "nz", "#election"
|
85
|
+
action :growl
|
86
|
+
end
|
87
|
+
|
88
|
+
#### Ping
|
89
|
+
|
90
|
+
To ping a URI, pass Herald `action :ping, :uri => "http://address.to.ping"`:
|
91
|
+
|
92
|
+
Herald.watch_twitter do
|
93
|
+
_for "#yaks", "#in", "#space"
|
94
|
+
action :ping, :uri => "http://counter.com/+1"
|
95
|
+
end
|
96
|
+
|
97
|
+
#### Post
|
98
|
+
|
99
|
+
To post information about what Herald finds to a URI, pass Herald `action :post, :uri => "http://address.to.post.to"`:
|
100
|
+
|
101
|
+
Herald.watch_twitter do
|
102
|
+
_for "#yaks", "#in", "#space"
|
103
|
+
action :post, :uri => "http://yakdb.com/post"
|
104
|
+
end
|
105
|
+
|
106
|
+
#### Callbacks
|
107
|
+
|
108
|
+
If you'd like to do your own thing entirely each time a keyword appears, pass a callback in the form of a Ruby block:
|
109
|
+
|
110
|
+
Herald.watch_twitter do
|
111
|
+
_for "revolution"
|
112
|
+
action do
|
113
|
+
`say "Viva!"`
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
### Timer
|
118
|
+
|
119
|
+
By default Herald will sleep for 1 minute after checking each of the sources independently.
|
120
|
+
To set a different sleep time:
|
121
|
+
|
122
|
+
Herald.watch_twitter do
|
123
|
+
_for "soundofmusic"
|
124
|
+
every 30 => "seconds" # or "minutes", "hours", or "days"
|
125
|
+
end
|
126
|
+
|
127
|
+
### Look Once
|
128
|
+
|
129
|
+
Rather than watching, if you just want to get a single poll of keywords, use `once()`. All the same parameters as with `watch()` can be used (except `every`).
|
130
|
+
|
131
|
+
Herald.once do
|
132
|
+
check :twitter
|
133
|
+
_for "#herald"
|
134
|
+
end
|
135
|
+
|
136
|
+
### Callback Scope and Herald Metaprogramming
|
137
|
+
|
138
|
+
Callbacks allow a great deal of reflection into the internals of Herald.
|
139
|
+
|
140
|
+
If the callback is passed with the scope of `Herald`, it will have access to the `Herald` methods and instance variables:
|
141
|
+
|
142
|
+
Herald.watch_twitter do
|
143
|
+
_for "#breaking", "news"
|
144
|
+
action do
|
145
|
+
puts instance_variables
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
If passed in within the scope of `Herald::Watcher`, it will have access to the particular `Watcher`'s methods and instance variables:
|
150
|
+
|
151
|
+
Herald.watch do
|
152
|
+
check :twitter do
|
153
|
+
_for "#breaking", "news"
|
154
|
+
action do
|
155
|
+
puts instance_variables
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
### For inquisitive minds
|
161
|
+
|
162
|
+
Assign the herald watcher to a variable
|
163
|
+
|
164
|
+
require 'herald'
|
165
|
+
herald = Herald.watch_rss :from => "http://www.reddit.com/r/pics/new/.rss" do
|
166
|
+
_for "imgur"
|
167
|
+
end
|
168
|
+
|
169
|
+
Edit your herald instance while it's working
|
170
|
+
|
171
|
+
herald.watchers
|
172
|
+
herald.watchers.to_s # in English
|
173
|
+
herald.watchers.first.keywords << "cats"
|
174
|
+
herald.watchers.first.action { puts "callback" }
|
175
|
+
|
176
|
+
Stop and start herald
|
177
|
+
|
178
|
+
herald.stop
|
179
|
+
herald.alive? # => false
|
180
|
+
herald.start
|
181
|
+
|
182
|
+
Start a second herald
|
183
|
+
|
184
|
+
herald_the_second = Herald.watch_twitter { _for "#herald" }
|
185
|
+
|
186
|
+
Use `Herald` class methods to inspect and edit heralds as a batch
|
187
|
+
|
188
|
+
Herald.heralds # prints both running heralds
|
189
|
+
Herald.heralds.to_s # print in English
|
190
|
+
Herald.stop # both heralds stopped
|
191
|
+
Herald.alive? # => false
|
192
|
+
Herald.start # both heralds restarted
|
193
|
+
|
194
|
+
### Herald Binary [Not Implemented]
|
195
|
+
|
196
|
+
herald -watch twitter -for #eqnz
|
197
|
+
herald -once twitter -for #herald
|
198
|
+
herald -show-heralds
|
199
|
+
herald -modify 1 -watch rss -from http://example.com/.rss
|
200
|
+
herald -pause 1
|
201
|
+
herald -kill 1
|
data/Rakefile
ADDED
data/TODO.txt
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
- Catch and report on exceptions in @@subprocess, or thread, when exception has killed a @@subprocess, remove it from @@subprocess
|
2
|
+
|
3
|
+
- Best way to require gems? Can I vendorise crack gem?
|
4
|
+
|
5
|
+
- Suppress Growl text on test()
|
6
|
+
|
7
|
+
- Comment in RDOC
|
8
|
+
|
9
|
+
- Handle exceptions in processes, by reporting the error, then set the @subprocess to nil
|
10
|
+
|
11
|
+
- Look at Gemspec
|
12
|
+
- Show dependency on ruby-growl if installed for mac
|
data/herald.gemspec
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "herald/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "herald"
|
7
|
+
s.version = Herald::VERSION
|
8
|
+
s.platform = Gem::Platform::RUBY
|
9
|
+
s.authors = ["Luke Duncalfe"]
|
10
|
+
s.email = ["lduncalfe@eml.cc"]
|
11
|
+
s.homepage = "http://github.com/lukes/herald"
|
12
|
+
s.summary = %q{A simple notifier for Twitter, RSS, or email}
|
13
|
+
s.description = %q{Pass Herald some keywords and sources to watch, and Herald will notify you using Growl, email, pinging a site, or running Ruby code as soon as those keywords appear}
|
14
|
+
|
15
|
+
s.required_rubygems_version = ">= 1.3.6" # TODO test earliest dependency
|
16
|
+
s.add_development_dependency "minitest"
|
17
|
+
s.add_dependency "crack"
|
18
|
+
s.rubyforge_project = "herald"
|
19
|
+
|
20
|
+
s.files = `git ls-files`.split("\n")
|
21
|
+
s.test_files = `git ls-files -- test/*`.split("\n")
|
22
|
+
# s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
23
|
+
s.require_paths = ["lib"]
|
24
|
+
end
|
data/lib/herald.rb
ADDED
@@ -0,0 +1,178 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
#require 'json'
|
3
|
+
gem 'crack'
|
4
|
+
require 'crack'
|
5
|
+
|
6
|
+
require 'herald/watcher'
|
7
|
+
require 'herald/notifier'
|
8
|
+
require 'herald/notifiers/stdout'
|
9
|
+
require 'herald/item'
|
10
|
+
|
11
|
+
class Herald
|
12
|
+
|
13
|
+
@@heralds = []
|
14
|
+
attr_accessor :watchers, :keep_alive, :subprocess
|
15
|
+
|
16
|
+
def self.watch(&block)
|
17
|
+
new(&block).start
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.once(&block)
|
21
|
+
herald = new()
|
22
|
+
herald.keep_alive = false
|
23
|
+
herald.send(:instance_eval, &block)
|
24
|
+
herald.start
|
25
|
+
end
|
26
|
+
|
27
|
+
# shorthand methods
|
28
|
+
def self.watch_twitter(&block)
|
29
|
+
watch() { check(:twitter, &block) }
|
30
|
+
end
|
31
|
+
def self.watch_rss(options = {}, &block)
|
32
|
+
watch() { check(:rss, options, &block) }
|
33
|
+
end
|
34
|
+
|
35
|
+
# batch methods
|
36
|
+
def self.start
|
37
|
+
return false if @@heralds.empty?
|
38
|
+
@@heralds.each do |herald|
|
39
|
+
herald.start
|
40
|
+
end
|
41
|
+
true
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.stop
|
45
|
+
return false unless Herald.alive?
|
46
|
+
@@heralds.each do |herald|
|
47
|
+
herald.stop
|
48
|
+
end
|
49
|
+
true
|
50
|
+
end
|
51
|
+
|
52
|
+
# stop all heralds, and remove them
|
53
|
+
# from list of herald instances. mostly
|
54
|
+
# useful for clearing @@heralds when testing
|
55
|
+
def self.clear
|
56
|
+
stop()
|
57
|
+
@@heralds.clear
|
58
|
+
true
|
59
|
+
end
|
60
|
+
|
61
|
+
# just walk away, leaving whatever strays to themselves
|
62
|
+
def self.demonize!
|
63
|
+
@@heralds.clear
|
64
|
+
true
|
65
|
+
end
|
66
|
+
|
67
|
+
def self.alive?
|
68
|
+
@@heralds.any? { |h| h.alive? }
|
69
|
+
end
|
70
|
+
|
71
|
+
def self.heralds
|
72
|
+
@@heralds
|
73
|
+
end
|
74
|
+
|
75
|
+
#
|
76
|
+
# instance methods:
|
77
|
+
#
|
78
|
+
|
79
|
+
def initialize(&block)
|
80
|
+
@watchers = []
|
81
|
+
@keep_alive = true
|
82
|
+
if block_given?
|
83
|
+
instance_eval(&block)
|
84
|
+
end
|
85
|
+
@@heralds << self
|
86
|
+
self
|
87
|
+
end
|
88
|
+
|
89
|
+
# create a new Watcher
|
90
|
+
def check(type, options = {}, &block)
|
91
|
+
@watchers << Herald::Watcher.new(type, @keep_alive, options, &block)
|
92
|
+
end
|
93
|
+
|
94
|
+
# send keywords to Watchers
|
95
|
+
def _for(*keywords)
|
96
|
+
@watchers.each do |watcher|
|
97
|
+
watcher._for(*keywords)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# send instructions to Watchers
|
102
|
+
def action(type = nil, options = {}, &block)
|
103
|
+
@watchers.each do |watcher|
|
104
|
+
watcher.action(type, options, &block)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# send sleep time to Watchers
|
109
|
+
def every(time)
|
110
|
+
@watchers.each do |watcher|
|
111
|
+
watcher.every(time)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# start Watchers
|
116
|
+
def start
|
117
|
+
if @watchers.empty?
|
118
|
+
raise "No watchers assigned"
|
119
|
+
end
|
120
|
+
# if herald is already running, first stop
|
121
|
+
stop() if alive?
|
122
|
+
# start watching as a @subprocess
|
123
|
+
@subprocess = fork {
|
124
|
+
@watchers.each do |watcher|
|
125
|
+
watcher.start
|
126
|
+
end
|
127
|
+
# all watchers do their tasks in a new thread.
|
128
|
+
# join all thread in this @subprocess
|
129
|
+
Thread.list.each do |thread|
|
130
|
+
thread.join unless thread == Thread.main
|
131
|
+
end
|
132
|
+
}
|
133
|
+
# if herald process is persistant
|
134
|
+
if @keep_alive
|
135
|
+
Process.detach(@subprocess)
|
136
|
+
else
|
137
|
+
# wait before the end of this script
|
138
|
+
# for all watchers to finish their jobs
|
139
|
+
Process.waitpid(@subprocess)
|
140
|
+
end
|
141
|
+
self # return instance object
|
142
|
+
end
|
143
|
+
|
144
|
+
# is there a gentler way of doing it?
|
145
|
+
# or have watchers do cleanup tasks on exit?
|
146
|
+
# look at GOD
|
147
|
+
def stop
|
148
|
+
Process.kill("TERM", @subprocess) if @subprocess
|
149
|
+
@subprocess = nil
|
150
|
+
self
|
151
|
+
end
|
152
|
+
alias :end :stop
|
153
|
+
alias :kill :stop
|
154
|
+
|
155
|
+
def alive?
|
156
|
+
!!@subprocess
|
157
|
+
end
|
158
|
+
|
159
|
+
def to_s
|
160
|
+
"#{alive? ? 'Alive' : 'Stopped'}Alive"
|
161
|
+
end
|
162
|
+
|
163
|
+
private
|
164
|
+
|
165
|
+
def self.lazy_load_module(module_path)
|
166
|
+
lazy_load("herald/#{module_path}")
|
167
|
+
end
|
168
|
+
|
169
|
+
def self.lazy_load(path)
|
170
|
+
require(path)
|
171
|
+
end
|
172
|
+
|
173
|
+
end
|
174
|
+
|
175
|
+
# queue a block to always stop all forked processes on exit
|
176
|
+
at_exit do
|
177
|
+
Herald.stop
|
178
|
+
end
|
data/lib/herald/item.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
class Herald
|
2
|
+
class Watcher
|
3
|
+
|
4
|
+
class Notifier
|
5
|
+
|
6
|
+
# TODO, make this dynamic? to allow people to write
|
7
|
+
# their own and drop it into notifiers dir
|
8
|
+
@@notifier_types = [:callback, :growl, :ping, :post, :stdout]
|
9
|
+
DEFAULT_NOTIFIER = :stdout
|
10
|
+
|
11
|
+
def initialize(type, options, &block)
|
12
|
+
type = type.to_sym
|
13
|
+
# check notifier type
|
14
|
+
unless @@notifier_types.include?(type)
|
15
|
+
raise ArgumentError, "#{type} is not a valid Notifier type"
|
16
|
+
end
|
17
|
+
Herald::lazy_load_module("notifiers/#{type}")
|
18
|
+
# extend class with module
|
19
|
+
send(:extend, eval(type.to_s.capitalize))
|
20
|
+
# each individual Notifier will handle their options
|
21
|
+
parse_options(options)
|
22
|
+
# if callback given
|
23
|
+
if block_given?
|
24
|
+
@proc = block
|
25
|
+
end
|
26
|
+
# call test() method of Notifier module
|
27
|
+
test()
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
class Herald
|
2
|
+
class Watcher
|
3
|
+
class Notifier
|
4
|
+
|
5
|
+
module Callback
|
6
|
+
|
7
|
+
attr_reader :proc
|
8
|
+
|
9
|
+
# no options for Callback
|
10
|
+
def parse_options(options); end
|
11
|
+
|
12
|
+
# we can't sandbox the proc to test it out,
|
13
|
+
# so just return true
|
14
|
+
def test; true; end
|
15
|
+
|
16
|
+
def notify(item)
|
17
|
+
@proc.call
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
class Herald
|
2
|
+
class Watcher
|
3
|
+
class Notifier
|
4
|
+
|
5
|
+
module Growl
|
6
|
+
|
7
|
+
# no options to parse for Growl
|
8
|
+
def parse_options(options); end
|
9
|
+
|
10
|
+
# test growl on system and throw exception if fail.
|
11
|
+
def test
|
12
|
+
# TODO suppress output
|
13
|
+
unless(system("growl --version"))
|
14
|
+
# TODO throw custom exception
|
15
|
+
raise "ruby-growl gem is not installed. Run:\n\tsudo gem install ruby-growl"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# send a Growl notification
|
20
|
+
def notify(item)
|
21
|
+
# response will be false if system() call exits with an error.
|
22
|
+
# presence of ruby-growl has already been tested in test(), so a false
|
23
|
+
# return is likely to be the user's Growl settings
|
24
|
+
# TODO escape characters in title and message
|
25
|
+
item.title.gsub!("'", '')
|
26
|
+
item.message.gsub!("'", '')
|
27
|
+
if !system("growl -H localhost -t '#{item.title}' -m '#{item.message}'")
|
28
|
+
# TODO throw custom exception
|
29
|
+
raise "Growl settings not configured to allow remote application registration. See Growl website docs: http://growl.info/documentation/exploring-preferences.php"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
class Herald
|
2
|
+
class Watcher
|
3
|
+
class Notifier
|
4
|
+
|
5
|
+
module Ping
|
6
|
+
|
7
|
+
attr_reader :uri
|
8
|
+
|
9
|
+
# lazy-load net/http when this Module is used
|
10
|
+
def self.extended(base)
|
11
|
+
Herald.lazy_load('net/http')
|
12
|
+
end
|
13
|
+
|
14
|
+
# note: dupe between ping and post
|
15
|
+
def parse_options(options)
|
16
|
+
begin
|
17
|
+
@uri = URI.parse(options.delete(:uri) || options.delete(:url))
|
18
|
+
# if URI lib can't resolve a protocol (because it was missing from string)
|
19
|
+
if @uri.class == URI::Generic
|
20
|
+
@uri = URI.parse("http://#{@uri.path}")
|
21
|
+
end
|
22
|
+
rescue URI::InvalidURIError
|
23
|
+
raise ArgumentError, ":uri for :ping action not specified or invalid"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def test
|
28
|
+
response = Net::HTTP.new(@uri.host).head('/')
|
29
|
+
return if response.kind_of?(Net::HTTPOK)
|
30
|
+
# TODO raise custom error types
|
31
|
+
if response.kind_of?(Net::HTTPFound)
|
32
|
+
raise "URI #{@uri} is being redirected to #{response.header['location']}"
|
33
|
+
else
|
34
|
+
raise "URI #{@uri} cannot be reached. Ping returned status code: #{response.code}"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def notify(item)
|
39
|
+
Net::HTTP.new(@uri.host).head('/')
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
class Herald
|
2
|
+
class Watcher
|
3
|
+
class Notifier
|
4
|
+
|
5
|
+
# note most of this code is duplicated between ping and post
|
6
|
+
module Post
|
7
|
+
|
8
|
+
attr_reader :uri
|
9
|
+
|
10
|
+
# lazy-load net/http when this Module is used
|
11
|
+
def self.extended(base)
|
12
|
+
Herald.lazy_load('net/http')
|
13
|
+
end
|
14
|
+
|
15
|
+
def parse_options(options)
|
16
|
+
begin
|
17
|
+
@uri = URI.parse(options.delete(:uri) || options.delete(:url))
|
18
|
+
# if URI lib can't resolve a protocol (because it was missing from string)
|
19
|
+
if @uri.class == URI::Generic
|
20
|
+
@uri = URI.parse("http://#{@uri.path}")
|
21
|
+
end
|
22
|
+
rescue URI::InvalidURIError
|
23
|
+
raise ArgumentError, ":uri for :ping action not specified or invalid"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# test by pinging to URI throw exception if fail
|
28
|
+
def test
|
29
|
+
response = Net::HTTP.new(@uri.host).head('/')
|
30
|
+
return if response.kind_of?(Net::HTTPOK)
|
31
|
+
# TODO raise custom error types
|
32
|
+
if response.kind_of?(Net::HTTPFound)
|
33
|
+
raise "URI #{@uri} is being redirected to #{response.header['location']}"
|
34
|
+
else
|
35
|
+
raise "URI #{@uri} cannot be reached. Ping returned status code: #{response.code}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def notify(item)
|
40
|
+
Net::HTTP.post_form(@uri.to_s, { "title" => item.title, "message" => item.message })
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
class Herald
|
2
|
+
class Watcher
|
3
|
+
class Notifier
|
4
|
+
|
5
|
+
module Stdout
|
6
|
+
|
7
|
+
def parse_options(options)
|
8
|
+
# option to send stdout to file
|
9
|
+
if file = options.delete(:file)
|
10
|
+
$stdout = File.new(file, 'w')
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
# is always working, so true
|
15
|
+
def test; true; end
|
16
|
+
|
17
|
+
# print to $stdout
|
18
|
+
def notify(item)
|
19
|
+
$stdout.puts item.data.inspect
|
20
|
+
$stdout.flush
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
class Herald
|
2
|
+
|
3
|
+
class Watcher
|
4
|
+
|
5
|
+
@@watcher_types = [:rss, :twitter] # :imap
|
6
|
+
DEFAULT_TIMER = 60
|
7
|
+
|
8
|
+
attr_reader :type, :keep_alive, :thread, :items
|
9
|
+
attr_accessor :notifiers, :keywords, :timer
|
10
|
+
|
11
|
+
def initialize(type, keep_alive, options, &block)
|
12
|
+
@type = type.to_sym
|
13
|
+
# TODO this is prepared to handle other protocols, but might not be necessary
|
14
|
+
# if @type == :inbox
|
15
|
+
# if options.key?(:imap)
|
16
|
+
# @type = :imap
|
17
|
+
# options[:host] = options[:imap]
|
18
|
+
# end
|
19
|
+
# end
|
20
|
+
# check watcher type
|
21
|
+
unless @@watcher_types.include?(@type)
|
22
|
+
raise ArgumentError, "#{@type} is not a valid Watcher type"
|
23
|
+
end
|
24
|
+
@keep_alive = keep_alive
|
25
|
+
@keywords = []
|
26
|
+
@notifiers = []
|
27
|
+
@items = []
|
28
|
+
@timer = Watcher::DEFAULT_TIMER
|
29
|
+
Herald.lazy_load_module("watchers/#{@type}")
|
30
|
+
# extend class with module
|
31
|
+
send(:extend, eval(@type.to_s.capitalize))
|
32
|
+
# each individual Watcher will handle their options
|
33
|
+
parse_options(options)
|
34
|
+
# eval the block, if given
|
35
|
+
if block_given?
|
36
|
+
instance_eval(&block)
|
37
|
+
end
|
38
|
+
# TODO implement a Watcher::test()?
|
39
|
+
end
|
40
|
+
|
41
|
+
def _for(*keywords)
|
42
|
+
@keywords += keywords.flatten
|
43
|
+
end
|
44
|
+
|
45
|
+
# assign the Notifier
|
46
|
+
def action(type = :callback, options = {}, &block)
|
47
|
+
@notifiers << Herald::Watcher::Notifier.new(type, options, &block)
|
48
|
+
end
|
49
|
+
|
50
|
+
# parse a hash like { 120 => "seconds" }
|
51
|
+
def every(time);
|
52
|
+
quantity = time.keys.first.to_i
|
53
|
+
unit = case time.values.first
|
54
|
+
when /^second/
|
55
|
+
1
|
56
|
+
when /^minute/
|
57
|
+
60
|
58
|
+
when /^hour/
|
59
|
+
3600
|
60
|
+
when /^day/
|
61
|
+
86400
|
62
|
+
else
|
63
|
+
raise ArgumentError, "Invalid time unit for every. (Use seconds, minutes, hours or days)"
|
64
|
+
end
|
65
|
+
@timer = quantity * unit
|
66
|
+
end
|
67
|
+
|
68
|
+
# call the Notifier and pass it a message
|
69
|
+
def notify(item)
|
70
|
+
@notifiers.each do |notifier|
|
71
|
+
notifier.notify(item)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def start
|
76
|
+
# set a default Notifier for this Watcher
|
77
|
+
action(Watcher::Notifier::DEFAULT_NOTIFIER) if @notifiers.empty?
|
78
|
+
# prepare() is defined in the individual Watcher modules.
|
79
|
+
# any pre-tasks are performed before
|
80
|
+
prepare()
|
81
|
+
# begin loop, which will execute at least once (like a do-while loop)
|
82
|
+
@thread = Thread.new {
|
83
|
+
begin
|
84
|
+
activities
|
85
|
+
sleep @timer if @keep_alive
|
86
|
+
end while @keep_alive
|
87
|
+
}
|
88
|
+
end
|
89
|
+
|
90
|
+
def stop
|
91
|
+
# stop looping
|
92
|
+
@keep_alive = false
|
93
|
+
# cleanup() is defined in the individual Watcher modules
|
94
|
+
cleanup()
|
95
|
+
end
|
96
|
+
|
97
|
+
end
|
98
|
+
|
99
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
class Herald
|
2
|
+
class Watcher
|
3
|
+
|
4
|
+
module Imap
|
5
|
+
|
6
|
+
attr_accessor :user, :pass, :host, :mailbox, :last_uid
|
7
|
+
|
8
|
+
# lazy-load net/imap when this Module is used
|
9
|
+
def self.extended(base)
|
10
|
+
Herald.lazy_load('net/imap')
|
11
|
+
Herald.lazy_load('date')
|
12
|
+
end
|
13
|
+
|
14
|
+
def parse_options(options)
|
15
|
+
@user = options.delete(:user)
|
16
|
+
@pass = options.delete(:pass)
|
17
|
+
@host = options.delete(:host)
|
18
|
+
@mailbox = options.delete(:mailbox) || "INBOX"
|
19
|
+
@mailbox.upcase!
|
20
|
+
# start looking a week ago unless option given
|
21
|
+
@start_date = options.delete(:start_date) || (Date.today - 7).strftime("%d-%b-%Y")
|
22
|
+
end
|
23
|
+
|
24
|
+
def prepare; end
|
25
|
+
def cleanup; end
|
26
|
+
|
27
|
+
def to_s
|
28
|
+
"Herald IMAP Watcher, Host: #{@host}, Keywords: '#{@keywords}', Timer: #{@timer}, State: #{@keep_alive ? 'Watching' : 'Stopped'}"
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def activities
|
34
|
+
puts "u: #{@user}"
|
35
|
+
puts "p: #{@pass}"
|
36
|
+
puts "h: #{@host}"
|
37
|
+
imap = Net::IMAP.new(@host)
|
38
|
+
imap.login("LOGIN", @user, @pass)
|
39
|
+
imap.select(@mailbox)
|
40
|
+
@last_look = Time.now
|
41
|
+
# if we have the id of the last email looked at
|
42
|
+
if @last_uid
|
43
|
+
search_result = imap.search(["BODY", @keywords, "SINCE", @start_date])
|
44
|
+
else
|
45
|
+
search_result = imap.search(["BODY", @keywords, "SINCE", @start_date])
|
46
|
+
end
|
47
|
+
puts search_result.inspect
|
48
|
+
imap.logout
|
49
|
+
imap.disconnect
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
class Herald
|
2
|
+
class Watcher
|
3
|
+
|
4
|
+
module Rss
|
5
|
+
|
6
|
+
attr_accessor :uris
|
7
|
+
|
8
|
+
# lazy-load open-uri when this Module is used
|
9
|
+
def self.extended(base)
|
10
|
+
Herald.lazy_load('open-uri')
|
11
|
+
end
|
12
|
+
|
13
|
+
def parse_options(options)
|
14
|
+
@uris = Array(options.delete(:from))
|
15
|
+
if @uris.empty?
|
16
|
+
raise ArgumentError, "RSS source not specified in :from Hash"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def prepare; end
|
21
|
+
def cleanup; end
|
22
|
+
|
23
|
+
def to_s
|
24
|
+
"Herald RSS Watcher, URL: #{@uris}, Keywords: '#{@keywords}', Timer: #{@timer}, State: #{@keep_alive ? 'Watching' : 'Stopped'}"
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def activities
|
30
|
+
@uris.each do |uri|
|
31
|
+
# return response as string and parse to RSS
|
32
|
+
begin
|
33
|
+
rss = Crack::XML.parse(open(uri).read)
|
34
|
+
rescue
|
35
|
+
return
|
36
|
+
end
|
37
|
+
# skip if rss variable is nil, or is missing
|
38
|
+
# rss elements in the expected nested format
|
39
|
+
next unless defined?(rss["rss"]["channel"]["item"])
|
40
|
+
# ignore items that have been part of a notification round
|
41
|
+
# or that don't contain the keywords being looked for
|
42
|
+
items = []
|
43
|
+
rss["rss"]["channel"]["item"].each do |item|
|
44
|
+
# if we've already seen this item, skip to next item
|
45
|
+
if @items.include?(item)
|
46
|
+
next
|
47
|
+
end
|
48
|
+
# keep this item if it contains keywords in the title or description
|
49
|
+
if "#{item["title"]}#{item["description"]}".match(/#{@keywords.join('|')}/i)
|
50
|
+
items << item
|
51
|
+
end
|
52
|
+
end
|
53
|
+
return if items.empty?
|
54
|
+
items.each do |item|
|
55
|
+
notify(Item.new(item["title"], item["description"], item))
|
56
|
+
end
|
57
|
+
@items += items
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
class Herald
|
2
|
+
class Watcher
|
3
|
+
|
4
|
+
# TODO, ignore retweets, if option passed
|
5
|
+
module Twitter
|
6
|
+
|
7
|
+
attr_accessor :uri, :last_tweet_id
|
8
|
+
TWITTER_API = "http://search.twitter.com/search.json"
|
9
|
+
|
10
|
+
# lazy-load open-uri when this Module is used
|
11
|
+
def self.extended(base)
|
12
|
+
Herald.lazy_load('open-uri')
|
13
|
+
end
|
14
|
+
|
15
|
+
def parse_options(options); end
|
16
|
+
|
17
|
+
# executed before Watcher starts
|
18
|
+
def prepare
|
19
|
+
# twitter doesn't allow you to query its api without
|
20
|
+
# providing a query string
|
21
|
+
if @keywords.empty?
|
22
|
+
raise ArgumentError, "Keywords are missing"
|
23
|
+
end
|
24
|
+
# initialise array, first element will be the Twitter API with search query string,
|
25
|
+
# the second element will be a "since_id" extra query parameter, added at close of
|
26
|
+
# activities() loop
|
27
|
+
@uri = ["#{TWITTER_API}?q=#{@keywords.join('+')}"]
|
28
|
+
end
|
29
|
+
|
30
|
+
def cleanup; end
|
31
|
+
|
32
|
+
def to_s
|
33
|
+
"Herald Twitter Watcher, Keywords: '#{@keywords}', Timer: #{@timer}, State: #{@keep_alive ? 'Watching' : 'Stopped'}"
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def activities
|
39
|
+
# return response as string from Twitter and parse it to JSON
|
40
|
+
json = Crack::JSON.parse(open(@uri.join("&")).read)
|
41
|
+
# will be nil if there are no results
|
42
|
+
return if json["results"].nil?
|
43
|
+
@last_tweet_id = json["max_id"]
|
44
|
+
json["results"].each do |tweet|
|
45
|
+
@items << tweet
|
46
|
+
notify(Item.new("@#{tweet['from_user']}", tweet['text'], json['results']))
|
47
|
+
end
|
48
|
+
@uri = [@uri.first, "since_id=#{@last_tweet_id}"]
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
end
|
data/test/herald_test.rb
ADDED
@@ -0,0 +1,110 @@
|
|
1
|
+
# start tests by 'rake test' on root path
|
2
|
+
|
3
|
+
describe Herald do
|
4
|
+
before do
|
5
|
+
@herald = Herald.watch { check :twitter; _for "test" }
|
6
|
+
end
|
7
|
+
|
8
|
+
after do
|
9
|
+
Herald.clear
|
10
|
+
end
|
11
|
+
|
12
|
+
describe "initialisation with watchers" do
|
13
|
+
it "must throw an acception if no 'check' param is given" do
|
14
|
+
assert_raises(RuntimeError) do
|
15
|
+
Herald.watch {}
|
16
|
+
end
|
17
|
+
end
|
18
|
+
it "must assign a watcher" do
|
19
|
+
@herald.watchers.size.must_equal(1)
|
20
|
+
end
|
21
|
+
it "must allow assigning multiple watchers" do
|
22
|
+
herald = Herald.watch { check :rss, :from => "http://example.com"; check :twitter; _for "test" }
|
23
|
+
herald.watchers.size.must_equal(2)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
describe "initialisation of twitter watcher" do
|
28
|
+
it "must throw an error if no keywords are given" do
|
29
|
+
skip("Test not working")
|
30
|
+
assert_raises(ArgumentError) do
|
31
|
+
Herald.watch { check :twitter }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
it "must assign a twitter watcher" do
|
35
|
+
@herald.watchers.first.type.must_equal(:twitter)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
describe "initialisation of rss watcher" do
|
40
|
+
it "must throw an error if no rss url is given" do
|
41
|
+
assert_raises(ArgumentError) do
|
42
|
+
Herald.watch { check :rss }
|
43
|
+
end
|
44
|
+
end
|
45
|
+
it "must assign an rss watcher" do
|
46
|
+
herald = Herald.watch { check :rss, :from => "http://example.com" }
|
47
|
+
herald.watchers.first.type.must_equal(:rss)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
describe "initialisation with keywords" do
|
52
|
+
it "must assign keyword when passed as a string" do
|
53
|
+
@herald.watchers.first.keywords.must_be_kind_of(Array)
|
54
|
+
@herald.watchers.first.keywords.size.must_equal(1)
|
55
|
+
@herald.watchers.first.keywords.to_s.must_equal("test")
|
56
|
+
end
|
57
|
+
it "must slurp keywords when passed as a multiargument strings" do
|
58
|
+
keywords = ["test1", "test2"]
|
59
|
+
herald = Herald.watch { check :twitter; _for *keywords }
|
60
|
+
herald.watchers.first.keywords.size.must_equal(2)
|
61
|
+
herald.watchers.first.keywords.to_s.must_equal(keywords.to_s)
|
62
|
+
end
|
63
|
+
it "must assign keywords when passed as an array" do
|
64
|
+
keywords = ["test1", "test2"]
|
65
|
+
herald = Herald.watch { check :twitter; _for keywords }
|
66
|
+
herald.watchers.first.keywords.size.must_equal(2)
|
67
|
+
herald.watchers.first.keywords.to_s.must_equal(keywords.to_s)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
describe "initialisation with actions" do
|
72
|
+
it "should assign stdout as the default Notifier" do
|
73
|
+
skip("@notifiers appears as empty, possible due to threading?")
|
74
|
+
@herald.watchers.first.notifiers.size.must_equal(1)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
describe "Herald class methods (stop, start, alive?) must allow batch operations" do
|
79
|
+
it "must return array of running heralds" do
|
80
|
+
Herald.heralds.must_be_kind_of(Array)
|
81
|
+
Herald.heralds.size.must_equal(1)
|
82
|
+
Herald.heralds.first.must_be_kind_of(Herald)
|
83
|
+
herald_2 = Herald.watch { check :twitter; _for "test" }
|
84
|
+
Herald.heralds.size.must_equal(2)
|
85
|
+
end
|
86
|
+
it "must report on if there are any heralds alive" do
|
87
|
+
Herald.alive?.must_equal(true)
|
88
|
+
end
|
89
|
+
it "must allow heralds to be stopped" do
|
90
|
+
Herald.stop.must_equal(true)
|
91
|
+
end
|
92
|
+
it "alive? must report false if heralds exist, but none are running" do
|
93
|
+
Herald.stop
|
94
|
+
Herald.heralds.size.must_equal(1)
|
95
|
+
Herald.alive?.must_equal(false)
|
96
|
+
end
|
97
|
+
it "must report false if heralds are told to stop, and no herald are alive" do
|
98
|
+
Herald.stop
|
99
|
+
Herald.stop.must_equal(false)
|
100
|
+
end
|
101
|
+
it "must allow heralds to be restarted" do
|
102
|
+
Herald.stop
|
103
|
+
Herald.start
|
104
|
+
Herald.alive?.must_equal(true)
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
108
|
+
|
109
|
+
|
110
|
+
end
|
metadata
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: herald
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 9
|
5
|
+
prerelease:
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 1
|
9
|
+
version: "0.1"
|
10
|
+
platform: ruby
|
11
|
+
authors:
|
12
|
+
- Luke Duncalfe
|
13
|
+
autorequire:
|
14
|
+
bindir: bin
|
15
|
+
cert_chain: []
|
16
|
+
|
17
|
+
date: 2011-04-14 00:00:00 +12:00
|
18
|
+
default_executable:
|
19
|
+
dependencies:
|
20
|
+
- !ruby/object:Gem::Dependency
|
21
|
+
name: minitest
|
22
|
+
prerelease: false
|
23
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
24
|
+
none: false
|
25
|
+
requirements:
|
26
|
+
- - ">="
|
27
|
+
- !ruby/object:Gem::Version
|
28
|
+
hash: 3
|
29
|
+
segments:
|
30
|
+
- 0
|
31
|
+
version: "0"
|
32
|
+
type: :development
|
33
|
+
version_requirements: *id001
|
34
|
+
- !ruby/object:Gem::Dependency
|
35
|
+
name: crack
|
36
|
+
prerelease: false
|
37
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
38
|
+
none: false
|
39
|
+
requirements:
|
40
|
+
- - ">="
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
hash: 3
|
43
|
+
segments:
|
44
|
+
- 0
|
45
|
+
version: "0"
|
46
|
+
type: :runtime
|
47
|
+
version_requirements: *id002
|
48
|
+
description: Pass Herald some keywords and sources to watch, and Herald will notify you using Growl, email, pinging a site, or running Ruby code as soon as those keywords appear
|
49
|
+
email:
|
50
|
+
- lduncalfe@eml.cc
|
51
|
+
executables: []
|
52
|
+
|
53
|
+
extensions: []
|
54
|
+
|
55
|
+
extra_rdoc_files: []
|
56
|
+
|
57
|
+
files:
|
58
|
+
- .gitignore
|
59
|
+
- Gemfile
|
60
|
+
- README.md
|
61
|
+
- Rakefile
|
62
|
+
- TODO.txt
|
63
|
+
- herald.gemspec
|
64
|
+
- lib/herald.rb
|
65
|
+
- lib/herald/item.rb
|
66
|
+
- lib/herald/notifier.rb
|
67
|
+
- lib/herald/notifiers/callback.rb
|
68
|
+
- lib/herald/notifiers/growl.rb
|
69
|
+
- lib/herald/notifiers/ping.rb
|
70
|
+
- lib/herald/notifiers/post.rb
|
71
|
+
- lib/herald/notifiers/stdout.rb
|
72
|
+
- lib/herald/version.rb
|
73
|
+
- lib/herald/watcher.rb
|
74
|
+
- lib/herald/watchers/imap.rb
|
75
|
+
- lib/herald/watchers/rss.rb
|
76
|
+
- lib/herald/watchers/twitter.rb
|
77
|
+
- test/herald_test.rb
|
78
|
+
has_rdoc: true
|
79
|
+
homepage: http://github.com/lukes/herald
|
80
|
+
licenses: []
|
81
|
+
|
82
|
+
post_install_message:
|
83
|
+
rdoc_options: []
|
84
|
+
|
85
|
+
require_paths:
|
86
|
+
- lib
|
87
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
88
|
+
none: false
|
89
|
+
requirements:
|
90
|
+
- - ">="
|
91
|
+
- !ruby/object:Gem::Version
|
92
|
+
hash: 3
|
93
|
+
segments:
|
94
|
+
- 0
|
95
|
+
version: "0"
|
96
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
97
|
+
none: false
|
98
|
+
requirements:
|
99
|
+
- - ">="
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
hash: 23
|
102
|
+
segments:
|
103
|
+
- 1
|
104
|
+
- 3
|
105
|
+
- 6
|
106
|
+
version: 1.3.6
|
107
|
+
requirements: []
|
108
|
+
|
109
|
+
rubyforge_project: herald
|
110
|
+
rubygems_version: 1.6.2
|
111
|
+
signing_key:
|
112
|
+
specification_version: 3
|
113
|
+
summary: A simple notifier for Twitter, RSS, or email
|
114
|
+
test_files:
|
115
|
+
- test/herald_test.rb
|