joggle 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,139 @@
1
+ require 'optparse'
2
+ require 'joggle/version'
3
+ require 'joggle/config-parser'
4
+ require 'joggle/cli/option-parser'
5
+
6
+ module Joggle
7
+ module CLI
8
+ #
9
+ # Option parser for Joggle command-line interface.
10
+ #
11
+ class OptionParser
12
+ #
13
+ # Default configuration.
14
+ #
15
+ DEFAULTS = {
16
+ # pull default jabber username and password from environment
17
+ 'jabber.user' => ENV['JOGGLE_USERNAME'],
18
+ 'jabber.pass' => ENV['JOGGLE_PASSWORD'],
19
+ }
20
+
21
+ #
22
+ # Create and run a new command-line option parser.
23
+ #
24
+ def self.run(app, args)
25
+ new(app).run(args)
26
+ end
27
+
28
+ #
29
+ # Create new command-line option parser.
30
+ #
31
+ def initialize(app)
32
+ @app = app
33
+ end
34
+
35
+ #
36
+ # Run command-line option parser.
37
+ #
38
+ def run(args)
39
+ ret = DEFAULTS.merge({})
40
+
41
+ # create option parser
42
+ o = ::OptionParser.new do |o|
43
+ o.banner = "Usage: #@app [options]"
44
+ o.separator " "
45
+
46
+ # add command-line options
47
+ o.separator "Options:"
48
+
49
+ o.on('-A', '--allow USER', 'Allow Jabber subscription from USER.') do |v|
50
+ add_allowed(ret, v)
51
+ end
52
+
53
+ o.on('-c', '--config FILE', 'Use configuration file FILE.') do |v|
54
+ Joggle::ConfigParser.run(v) do |key, val|
55
+ if key == 'engine.allow'
56
+ add_allowed(ret, val)
57
+ elsif key == 'engine.update.range'
58
+ add_update_range(ret, val)
59
+ else
60
+ ret[key] = val
61
+ end
62
+ end
63
+ end
64
+
65
+ o.on('-D', '--daemon', 'Run as daemon (in background).') do |v|
66
+ ret['cli.daemon'] = true
67
+ end
68
+
69
+ o.on('--foreground', 'Run in foreground (the default).') do |v|
70
+ ret['cli.daemon'] = false
71
+ end
72
+
73
+ o.on('-L', '--log-level LEVEL', 'Set log level to LEVEL.') do |v|
74
+ ret['runner.log.level'] = v
75
+ end
76
+
77
+ o.on('-l', '--log FILE', 'Log to FILE.') do |v|
78
+ ret['runner.log.path'] = v
79
+ end
80
+
81
+ o.on('-p', '--password PASS', 'Jabber password (INSECURE!).') do |v|
82
+ ret['jabber.pass'] = v
83
+ end
84
+
85
+ o.on('-s', '--store FILE', 'Use FILE as backing store.') do |v|
86
+ ret['runner.store.path'] = v
87
+ end
88
+
89
+ o.on('-u', '--username USER', 'Jabber username.') do |v|
90
+ ret['jabber.user'] = v
91
+ end
92
+
93
+ o.separator ' '
94
+
95
+ o.on_tail('-v', '--version', 'Print version string.') do
96
+ puts "Joggle %s, by %s" % [
97
+ Joggle::VERSION,
98
+ 'Paul Duncan <pabs@pablotron.org>',
99
+ ]
100
+ exit
101
+ end
102
+
103
+ o.on_tail('-h', '--help', 'Print help information.') do
104
+ puts o
105
+ exit
106
+ end
107
+ end
108
+
109
+ # parse arguments
110
+ o.parse(args)
111
+
112
+ # return results
113
+ ret
114
+ end
115
+
116
+ private
117
+
118
+ #
119
+ # Add an allowed user.
120
+ #
121
+ def add_allowed(ret, val)
122
+ return unless val && val =~ /\S/
123
+
124
+ ret['engine.allow'] ||= []
125
+ ret['engine.allow'].concat(val.strip.downcase.split(/\s*,\s*/))
126
+ end
127
+
128
+ def add_update_range(ret, val)
129
+ return unless val && val =~ /\S/
130
+
131
+ if md = val.match(/(\d+)\s*-\s*(\d+)\s+(\d+)/)
132
+ key, mins = "#{md[1]}-#{md[2]}", md[3].to_i
133
+ ret['engine.update.range'] ||= {}
134
+ ret['engine.update.range'][key] = mins
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,47 @@
1
+ require 'joggle/runner/pstore'
2
+ require 'joggle/cli/option-parser'
3
+
4
+ module Joggle
5
+ module CLI
6
+ #
7
+ # Basic command-line interface for Joggle.
8
+ #
9
+ class Runner
10
+ #
11
+ # Create and run a CLI object.
12
+ #
13
+ def self.run(app, args)
14
+ new(app, args).run
15
+ end
16
+
17
+ #
18
+ # Create CLI object.
19
+ #
20
+ def initialize(app, args)
21
+ @opt = OptionParser.run(app, args)
22
+ end
23
+
24
+ #
25
+ # Run command-line interface.
26
+ #
27
+ def run
28
+ if @opt['cli.daemon']
29
+ pid = Process.fork {
30
+ Joggle::Runner::PStore.run(@opt)
31
+ exit 0;
32
+ }
33
+
34
+ # detach from background process
35
+ Process.detach(pid)
36
+
37
+ # print process id and exit
38
+ $stderr.puts "Detached from pid #{pid}"
39
+ else
40
+ Joggle::Runner::PStore.run(@opt)
41
+ end
42
+
43
+ exit 0;
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,163 @@
1
+ module Joggle
2
+ #
3
+ # Mixin to handle commands.
4
+ #
5
+ module Commands
6
+ #
7
+ # Handle .register command.
8
+ #
9
+ def do_register(who, arg)
10
+ # see if user is registered
11
+ if user = @tweeter.registered?(who)
12
+ # user is registered, return error
13
+ msg = "Already registered as #{user}"
14
+ else
15
+ # user isn't registered, so add them
16
+
17
+ # get twitter username and password from argument
18
+ user, pass = arg.split(/\s+/, 2)
19
+
20
+ # register user
21
+ begin
22
+ @tweeter.register(who, user, pass)
23
+ msg = "Registered as #{user}"
24
+ rescue Exception => err
25
+ msg = "Couldn't register: #{err}"
26
+ end
27
+ end
28
+
29
+ # reply to request
30
+ reply(who, msg)
31
+ end
32
+
33
+ #
34
+ # Handle .unregister command.
35
+ #
36
+ def do_unregister(who, arg)
37
+ # see if user is registered
38
+ if @tweeter.registered?(who)
39
+ # user is registerd, so unregister them
40
+
41
+ begin
42
+ # unregister user
43
+ @tweeter.unregister(who)
44
+
45
+ # send success
46
+ msg = "Unregistered."
47
+ rescue Exception => err
48
+ msg = "Couldn't unregister: #{err}"
49
+ end
50
+ else
51
+ # user isn't registered, send error
52
+ msg = "Not registered."
53
+ end
54
+
55
+ # reply to request
56
+ reply(who, msg)
57
+ end
58
+
59
+ #
60
+ # Handle .list command.
61
+ #
62
+ def do_list(who, arg)
63
+ # see if user is registered
64
+ if @tweeter.registered?(who)
65
+ # user is registerd, so unregister them
66
+
67
+ begin
68
+ msgs = []
69
+
70
+ # build list
71
+ @tweeter.list(who) do |id, time, from, msg|
72
+ msgs << make_response(id, time, from, msg)
73
+ end
74
+
75
+ # build response
76
+ if msgs.size > 0
77
+ msg = msgs.join("\n")
78
+ else
79
+ msg = 'No tweets.'
80
+ end
81
+ rescue Exception => err
82
+ msg = "Couldn't list tweets: #{err}"
83
+ end
84
+ else
85
+ # user isn't registered, send error
86
+ msg = "Not registered."
87
+ end
88
+
89
+ # reply to request
90
+ reply(who, msg)
91
+ end
92
+
93
+ #
94
+ # String constant for .help command.
95
+ #
96
+ HELP = [
97
+ "Joggle Help:",
98
+ "Available commands:",
99
+ " .help - Display this help screen.",
100
+ " .register <user> <pass> - Register Twitter username and password.",
101
+ " .unregister - Forget Twitter username and password.",
102
+ # TODO: " .force <msg> - Force tweet.",
103
+ # TODO: " .list - List recent tweets.",
104
+ "Any other message with two words or more is sent as a tweet. See the Joggle home page at http://pablotron.org/software/joggle/ for additional information",
105
+ ].join("\n")
106
+
107
+ #
108
+ # Handle .help command.
109
+ #
110
+ def do_help(who, arg)
111
+ reply(who, HELP)
112
+ end
113
+
114
+ EGGS = [
115
+ # movie quotes
116
+ "This is what happens, Larry!",
117
+ "Got a package, people!",
118
+ "Billy, do you like wrestling?",
119
+ "NO AND DEN!",
120
+ "I'm the dude playing the dude disguised as another dude.",
121
+ "Hey, want to hear the most annoying sound in the world?",
122
+ "Bueller?",
123
+ "Inconcievable!",
124
+
125
+ # non-movie quotes
126
+ "You are in a maze of twisty compiler features, all different.",
127
+ "You can tune a filesystem, but you can't tuna fish.",
128
+ "Never attribute to malice that which can adequately explained by stupidity.",
129
+ "The first thing to do when you find yourself in a hole is to stop digging.",
130
+ "I once knew a man who had a dog with only three legs, and yet that man could play the banjo like anything.",
131
+ "The needs of the many outweigh the needs of the guy who can't run fast.",
132
+ "It may look like I'm doing nothing, but I'm actively waiting for my problems to go away.",
133
+ "Nobody ever went broke underestimating the intelligence of the American people.",
134
+ "There are only two things wrong with C++: The initial concept and the implementation.",
135
+ "There are only two kinds of people, those who finish what they start, and so on.",
136
+
137
+ # exclamations
138
+ "I'LL SAY!!!",
139
+ "AND HOW!!!",
140
+ "Cha-ching!",
141
+ "Crikey!",
142
+ "Nope.",
143
+ "Sweet!",
144
+ "Blimey!",
145
+
146
+ # emoticons
147
+ ":-D",
148
+ "^_^",
149
+ ":(",
150
+ ":-)",
151
+ ":')-|-<",
152
+
153
+ # misc
154
+ "http://pablotron.org/offended",
155
+ "<INSERT WITTY REMARK HERE>",
156
+ "0xBEEFCAFE",
157
+ ]
158
+
159
+ def do_easteregg(who, arg)
160
+ reply(who, EGGS[rand(EGGS.size)])
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,37 @@
1
+ module Joggle
2
+ #
3
+ # Simple configuration file parser.
4
+ #
5
+ class ConfigParser
6
+ #
7
+ # Parse configuration file and pass each directive to the
8
+ # specified block.
9
+ #
10
+ def self.run(path, &block)
11
+ new(path).run(&block)
12
+ end
13
+
14
+ #
15
+ # Create a new config file parser.
16
+ #
17
+ def initialize(path)
18
+ @path = path
19
+ end
20
+
21
+ #
22
+ # Parse configuration file and pass each directive to the specified
23
+ # block.
24
+ #
25
+ def run(&block)
26
+ File.readlines(@path).each do |line|
27
+ next if line =~ /\s*#/ || line !~ /\S/
28
+ line = line.strip
29
+ key, val = line.split(/\s+/, 2)
30
+
31
+ if key && key.size > 0
32
+ block.call(key, val)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,276 @@
1
+ require 'joggle/pablotron/observable'
2
+ require 'joggle/commands'
3
+
4
+ module Joggle
5
+ #
6
+ # Joggle engine object. This is where the magic happens.
7
+ #
8
+ class Engine
9
+ include Joggle::Pablotron::Observable
10
+ include Commands
11
+
12
+ DEFAULTS = {
13
+ # output time format
14
+ 'engine.time_format' => '%H:%M',
15
+
16
+ # enable sanity checks
17
+ 'engine.message_sanity_checks' => true,
18
+
19
+ # update every 60 minutes by default
20
+ 'engine.update.default' => 60,
21
+
22
+ # time ranges for updates
23
+ 'engine.update.range' => {
24
+ # from 8am until 3pm, update every 10 minutes
25
+ '8-15' => 10,
26
+
27
+ # from 3pm until 10pm, update every 5 minutes
28
+ '15-22' => 5,
29
+
30
+ # from 10pm until midnight, update every 10 minutes
31
+ '22-24' => 10,
32
+
33
+ # from midnight until 2am, update every 20 minutes
34
+ '0-2' => 20,
35
+ },
36
+ }
37
+
38
+ #
39
+ # Create a new Joggle engine object.
40
+ #
41
+ def initialize(client, tweeter, opt = nil)
42
+ @opt = DEFAULTS.merge(opt || {})
43
+
44
+ @client = client
45
+ @client.on(self)
46
+
47
+ @tweeter = tweeter
48
+ end
49
+
50
+ #
51
+ # Run forever.
52
+ #
53
+ def run
54
+ loop {
55
+ # check for updates (if we need to)
56
+ if need_update?
57
+ update
58
+ end
59
+
60
+ # fire idle method
61
+ fire('engine_idle')
62
+
63
+ # sleep for thirty seconds
64
+ sleep 30
65
+ }
66
+ end
67
+
68
+ #
69
+ # Reply to a user with the given message.
70
+ #
71
+ def reply(who, msg)
72
+ begin
73
+ if fire('engine_before_reply', who, msg)
74
+ @client.deliver(who, msg)
75
+ fire('engine_reply', who, msg)
76
+ else
77
+ fire('engine_reply_stopped', who, msg, err)
78
+ end
79
+ rescue Exception => err
80
+ fire('engine_reply_error', who, msg, err)
81
+ end
82
+ end
83
+
84
+ ####################
85
+ # client listeners #
86
+ ####################
87
+
88
+ COMMAND_REGEX = /^\s*\.(\w+)\s*(\S.*|)\s*$/
89
+
90
+ #
91
+ # Jabber message listener callback.
92
+ #
93
+ def on_jabber_client_message(client, msg)
94
+ # get the message source
95
+ who = msg.from.to_s
96
+
97
+ # only listen to allowed users
98
+ if allowed?(who)
99
+ if md = msg.body.match(COMMAND_REGEX)
100
+ cmd = md[1].downcase
101
+ handle_command(who, cmd, md[2])
102
+ else
103
+ handle_message(who, msg.body)
104
+ end
105
+ else
106
+ fire('engine_ignored_message', who, msg)
107
+ end
108
+ end
109
+
110
+ #
111
+ # Jabber subscription listener callback.
112
+ #
113
+ def on_before_jabber_client_accept_subscription(client, who)
114
+ unless allowed?(who)
115
+ fire('engine_ignored_subscription', who)
116
+ raise Joggle::Pablotron::Observable::StopEvent, "denied subscription: #{who}"
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ def handle_command(who, cmd, arg)
123
+ # build method name
124
+ meth = "do_#{cmd}"
125
+
126
+ if respond_to?(meth)
127
+ # return if @tweeter.ignored?(who)
128
+ fire('engine_command', who, cmd, arg)
129
+ send("do_#{cmd}", who, arg)
130
+ else
131
+ # unknown commands
132
+ # FIXME: is this the correct behavior?
133
+ reply(who, "Unknown command: #{cmd}")
134
+ end
135
+ end
136
+
137
+ def handle_message(who, msg)
138
+ # remove extraneous whitespace
139
+ msg, out_msg = msg.strip, nil
140
+
141
+ # notify listeners
142
+ fire('engine_message', who, msg)
143
+
144
+ # make sure message isn't too long
145
+ if msg.length < 140
146
+ # make sure message is sane
147
+ if sane_message?(msg)
148
+ begin
149
+ row = @tweeter.tweet(who, msg)
150
+ out = "Done (id: #{row['id']})"
151
+ rescue Exception => err
152
+ out = "Error: #{err.backtrace.first}: #{err.message}"
153
+ end
154
+ else
155
+ out = "Error: Message is too short (try adding more words)"
156
+ end
157
+ else
158
+ out = 'Message length is greater than 140 characters'
159
+ end
160
+
161
+ # send reply
162
+ reply(who, out)
163
+ end
164
+
165
+ def allowed?(who)
166
+ # get list of allowed users
167
+ a = @opt['engine.allow']
168
+
169
+ # default to true
170
+ ret = true
171
+
172
+ if a && a.size > 0
173
+ ret = a.any? { |str| who.match(/^#{str}/i) }
174
+ end
175
+
176
+ # return result
177
+ ret
178
+ end
179
+
180
+ #
181
+ # Get the update interval for the given time
182
+ #
183
+ def get_update_interval(time = Time.now)
184
+ hour = time.hour
185
+ default, ranges = %w{default range}.map { |k| @opt["engine.update.#{k}"] }
186
+
187
+ if ranges
188
+ ranges.each do |key, val|
189
+ # get start/end hour
190
+ hours = key.split(/\s*-\s*/).map { |s| s.to_i }
191
+
192
+ # if this interval matches the given time, return it
193
+ if hour >= hours.first && hour <= hours.last
194
+ return val.to_i
195
+ end
196
+ end
197
+ end
198
+
199
+ # return the default interval
200
+ default.to_i
201
+ end
202
+
203
+ #
204
+ # Time of the next update
205
+ #
206
+ def next_update(time = Time.now)
207
+ # get the update interval for the given time
208
+ m = get_update_interval(time)
209
+ m = 5 if m < 5
210
+
211
+ (@last_update || 0) + (m * 60)
212
+ end
213
+
214
+ #
215
+ # Do we need an update?
216
+ #
217
+ def need_update?
218
+ return true unless @last_update
219
+
220
+ # get the current timestamp
221
+ now = Time.now
222
+
223
+ # return true if the last update was more than m minutes ago
224
+ next_update(now) < now.to_i
225
+ end
226
+
227
+ def update
228
+ begin
229
+ if fire('before_engine_update')
230
+ # save last update time
231
+ @last_update = Time.now.to_i
232
+
233
+ # send updates
234
+ @tweeter.update do |who, id, time, from, msg|
235
+ reply(who, make_response(id, time, from, msg))
236
+ end
237
+
238
+ # notify listeners
239
+ fire('engine_update')
240
+ else
241
+ fire('engine_update_stopped')
242
+ end
243
+ rescue Exception => err
244
+ fire('engine_update_error', err)
245
+ end
246
+ end
247
+
248
+ #
249
+ # Constraints to prevent garbage tweets.
250
+ #
251
+ MESSAGE_SANITY_CHECKS = [
252
+ # contains at least three consecutive word characters
253
+ proc { |m| m.match(/\w{3}/) },
254
+
255
+ # contains at least three, at least two of which are longer than
256
+ # three characters
257
+ proc { |m|
258
+ words = m.split(/\s+/)
259
+ (words.size > 2) && (words.select { |w| w && w.size > 2 }.size > 1)
260
+ },
261
+ ]
262
+
263
+ def sane_message?(msg)
264
+ # are sanity checks enabled?
265
+ return true unless @opt['engine.message_sanity_checks']
266
+
267
+ # pass message through all sanity checks
268
+ MESSAGE_SANITY_CHECKS.all? { |p| p.call(msg) }
269
+ end
270
+
271
+ def make_response(id, time, from, msg)
272
+ stamp = time.strftime(@opt['engine.time_format'])
273
+ "%s: %s (%s, #%d)" % [from, msg, stamp, id]
274
+ end
275
+ end
276
+ end