herald 0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|