joggle 0.1.0

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.
@@ -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