adammck-rubysms 0.8.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.
@@ -0,0 +1,50 @@
1
+ RubySMS is a Ruby library (not a framework, although it's coming dangerously close)
2
+ which aims to make developing and deploying SMS applications easy. The sending and
3
+ receiving of "real" SMS is handled by RubyGSM[http://github.com/adammck/rubygsm],
4
+ but RubySMS also provides a couple of ways to interact with "mock" SMS via a web
5
+ interface (the HTTP Backend) and GTK GUI (the DRB backend).
6
+
7
+ RubySMS was initially developed in Malawi by {The UNICEF Innovation Team}[http://unicefinnovation.org/about.php]
8
+ and a group from Columbia University's {SIPA}[http://sipa.columbia.edu/] as the technical foundation of the
9
+ {RapidSMS Child Malnutrition Survelliance}[http://netsquared.org/projects/child-malnutrition-surveillance-and-famine-response]
10
+ (see {the source code}[http://github.com/adammck/columbawawi]) entry to the {USAID Development 2.0 Challenge}[http://netsquared.org/usaid]...
11
+ which[http://globaldevelopmentcommons.net/node/876]
12
+ we[http://unicef.org/infobycountry/usa_47068.html]
13
+ won[http://sipa.columbia.edu/news_events/announcements/sipanews10.html].
14
+
15
+
16
+ === Sample Application
17
+
18
+ require "rubygems"
19
+ require "rubysms"
20
+
21
+ class DemoApp < SMS::App
22
+ def incoming(msg)
23
+ msg.respond("Wow, that was easy!")
24
+ end
25
+ end
26
+
27
+ DemoApp.serve!
28
+
29
+
30
+ === Installing
31
+ RubySMS is distributed via GitHub[http://github.com/adammck/rubysms], which you must
32
+ add as a Gem source before installing:
33
+
34
+ $ sudo gem sources -a http://gems.github.com
35
+
36
+ Then install the Gem, which will automatically pull in the dependencies:
37
+
38
+ $ sudo gem install adammck-rubysms
39
+
40
+
41
+ === Dependencies
42
+ If you'd prefer to run RubySMS from the trunk, you'll need to install the following
43
+ Gems manually (otherwise, the HTTP backend will explode on startup. Remind me to fix
44
+ that some time).
45
+
46
+ $ sudo gem install rack mongrel
47
+
48
+ If you'd like to send real SMS via a GSM modem, you'll also need RubyGSM:
49
+
50
+ $ sudo gem install adammck-rubygsm
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "libglade2.rb"
4
+ require "drb.rb"
5
+
6
+ SERVER_PORT = "1370"
7
+
8
+ class SmsGui
9
+ include GetText
10
+ attr :glade
11
+
12
+ def initialize
13
+
14
+ # if this file is a symlink (which is probably is, since rubygems
15
+ # automatically symlinks bin files into /usr/bin), find the original
16
+ # source file, so we can find the glade src relatively
17
+ this_file = File.symlink?(__FILE__) ? \
18
+ File.readlink(__FILE__) : __FILE__
19
+
20
+ # find and load up the glade src for the gui
21
+ glade_src = File.expand_path(File.dirname(this_file) + "/../lib/drb-client.glade")
22
+ @glade = GladeXML.new(glade_src) do |handler|
23
+ method(handler)
24
+ end
25
+
26
+ # for storing outgoing
27
+ # messages for recall
28
+ @history = []
29
+ @hist_pos = 0
30
+
31
+ # fetch references to the gtk widgets
32
+ @source = @glade.get_widget("entry_source")
33
+ @entry = @glade.get_widget("entry_message")
34
+ @log = @glade.get_widget("textview_log")
35
+ @lb = @log.buffer
36
+
37
+ # create a text mark to keep at the end of
38
+ # the log, so we can keep scrolling to it
39
+ @lb.create_mark("end", @lb.end_iter, true)
40
+
41
+ # create colors for incoming and outgoing messages
42
+ @lb.create_tag("incoming", "family"=>"monospace", "foreground"=>"#AA0000")
43
+ @lb.create_tag("outgoing", "family"=>"monospace", "foreground"=>"#0000AA")
44
+ @lb.create_tag("error", "family"=>"monospace", "foreground"=>"#FFFFFF", "background"=>"#FF0000")
45
+
46
+ # prepopulate the source phone number with six digits
47
+ @src = (1111 + rand(8888)).to_s
48
+ @source.text = @src
49
+
50
+ # fire up drb, to send outgoing messages to rubysms
51
+ @injector = DRbObject.new_with_uri("druby://localhost:#{SERVER_PORT}")
52
+ puts "Connected to RubySMS at: #{@injector.__drburi}"
53
+
54
+ # start listening for incoming messages from rubysms
55
+ @drb = DRb.start_service("druby://localhost:#{SERVER_PORT}#{@src}", self)
56
+ puts "Started DRb client at: #{@drb.uri}"
57
+
58
+ # display the GUI
59
+ @glade.get_widget("window").show
60
+ @entry.grab_focus
61
+ end
62
+
63
+
64
+ # user closed the window,
65
+ # so terminate the program
66
+ def on_quit
67
+ Gtk.main_quit
68
+ end
69
+
70
+
71
+ # user clicked "send", or pressed
72
+ # enter while in the entry field
73
+ def on_button_send_clicked
74
+ msg = @entry.text
75
+
76
+ # do nothing if the message is blank
77
+ return nil if msg.empty?
78
+ log ">> #{msg}", "incoming"
79
+
80
+ begin
81
+ # attempt to send the message
82
+ # to rubysms via drb, as if it
83
+ # were a real incoming sms
84
+ @injector.incoming(@src, msg)
85
+
86
+ @hist_pos = 0
87
+ @history.push(msg)
88
+ @entry.text = ""
89
+
90
+ # couldn't connect!
91
+ rescue DRb::DRbConnError
92
+ log("Connection to RubySMS failed!", "error")
93
+ #log("Tried URI: #{@injector.__drburi}", "error")
94
+ rescue => err
95
+ log(err.message, "error")
96
+ log(" " + err.backtrace.join("\n "), "error")
97
+ end
98
+ end
99
+
100
+ def on_entry_message_key_press(widget, event)
101
+ if event.keyval == 65362 # up arrow
102
+ if @hist_pos > -@history.length
103
+ @hist_pos -= 1
104
+ @entry.text = @history[@hist_pos]
105
+ @entry.grab_focus
106
+ end
107
+
108
+ # prevent default event,
109
+ # which bumps the focus
110
+ # upwards to the log
111
+ return true
112
+ end
113
+
114
+ if event.keyval == 65364 # down arrow
115
+ if @hist_pos < -1
116
+ @hist_pos += 1
117
+ @entry.text = @history[@hist_pos]
118
+ @entry.grab_focus
119
+ end
120
+
121
+ # prevent default
122
+ return true
123
+ end
124
+
125
+ # any other key resets
126
+ # the position in history
127
+ @hist_pos = 0
128
+ return false
129
+ end
130
+
131
+
132
+ def log(msg, tag)
133
+ # prepend a newline, unless this
134
+ # is the first entry (to avoid a
135
+ # one-line gap at the top)
136
+ msg = "\n" + msg\
137
+ if @lb.char_count > 0
138
+
139
+ # add a single entry to the message log
140
+ # (using the colors defined in #initialize)
141
+ @lb.insert(@lb.end_iter, msg, tag)
142
+
143
+ # scroll to the end of the *previous*
144
+ # message, to bring the top of this
145
+ # message into view, in case it is long
146
+ @log.scroll_to_mark(@lb.get_mark("end"), 0, true, 1, 0)
147
+
148
+ # update the position of the end marker,
149
+ # which we will scroll to (above), when
150
+ # the next message is logged
151
+ @lb.move_mark(@lb.get_mark("end"), @lb.end_iter)
152
+ @lb.end_iter.line_offset = 0
153
+ end
154
+
155
+
156
+ def incoming(msg)
157
+ # we received a message! do nothing
158
+ # except add it to the message log
159
+ msg.gsub!(/\n/, "\n ")
160
+ log "<< #{msg}", "outgoing"
161
+ end
162
+ end
163
+
164
+
165
+ # initialize, and block
166
+ # until GTK terminates
167
+ gui = SmsGui.new
168
+ Gtk.main
@@ -0,0 +1,90 @@
1
+ <?xml version="1.0"?>
2
+ <glade-interface>
3
+ <!--<requires-version lib="gtk+" version="2.12"/>-->
4
+ <widget class="GtkWindow" id="window">
5
+ <property name="border_width">6</property>
6
+ <property name="title" translatable="yes">RubySMS Virtual Device</property>
7
+ <property name="default_width">500</property>
8
+ <property name="default_height">300</property>
9
+ <property name="icon_name">mail-send-receive</property>
10
+ <signal name="delete_event" handler="on_quit"/>
11
+ <child>
12
+ <widget class="GtkVBox" id="vbox1">
13
+ <property name="visible">True</property>
14
+ <property name="spacing">6</property>
15
+ <child>
16
+ <widget class="GtkScrolledWindow" id="scrolledwindow1">
17
+ <property name="visible">True</property>
18
+ <property name="can_focus">True</property>
19
+ <property name="hscrollbar_policy">GTK_POLICY_AUTOMATIC</property>
20
+ <property name="shadow_type">GTK_SHADOW_IN</property>
21
+ <child>
22
+ <widget class="GtkTextView" id="textview_log">
23
+ <property name="visible">True</property>
24
+ <property name="can_focus">True</property>
25
+ <property name="events"></property>
26
+ <property name="editable">False</property>
27
+ </widget>
28
+ </child>
29
+ </widget>
30
+ </child>
31
+ <child>
32
+ <widget class="GtkTable" id="table2">
33
+ <property name="visible">True</property>
34
+ <property name="n_columns">3</property>
35
+ <property name="column_spacing">6</property>
36
+ <child>
37
+ <widget class="GtkEntry" id="entry_message">
38
+ <property name="visible">True</property>
39
+ <property name="can_focus">True</property>
40
+ <property name="has_tooltip">True</property>
41
+ <property name="tooltip" translatable="yes">Message text</property>
42
+ <property name="activates_default">True</property>
43
+ <signal name="key_press_event" handler="on_entry_message_key_press"/>
44
+ <accelerator key="T" signal="grab-focus" modifiers="GDK_MOD1_MASK"/>
45
+ </widget>
46
+ <packing>
47
+ <property name="left_attach">1</property>
48
+ <property name="right_attach">2</property>
49
+ <property name="y_options">GTK_FILL</property>
50
+ </packing>
51
+ </child>
52
+ <child>
53
+ <widget class="GtkButton" id="button_send">
54
+ <property name="visible">True</property>
55
+ <property name="can_focus">True</property>
56
+ <property name="can_default">True</property>
57
+ <property name="has_default">True</property>
58
+ <property name="receives_default">True</property>
59
+ <property name="label" translatable="yes">Send SMS</property>
60
+ <property name="response_id">0</property>
61
+ <signal name="clicked" handler="on_button_send_clicked"/>
62
+ </widget>
63
+ <packing>
64
+ <property name="left_attach">2</property>
65
+ <property name="right_attach">3</property>
66
+ <property name="x_options">GTK_FILL</property>
67
+ </packing>
68
+ </child>
69
+ <child>
70
+ <widget class="GtkEntry" id="entry_source">
71
+ <property name="width_request">60</property>
72
+ <property name="visible">True</property>
73
+ <property name="can_focus">True</property>
74
+ <property name="tooltip" translatable="yes">DRb TCP/IP Port</property>
75
+ <property name="editable">False</property>
76
+ </widget>
77
+ <packing>
78
+ <property name="x_options">GTK_FILL</property>
79
+ </packing>
80
+ </child>
81
+ </widget>
82
+ <packing>
83
+ <property name="expand">False</property>
84
+ <property name="position">1</property>
85
+ </packing>
86
+ </child>
87
+ </widget>
88
+ </child>
89
+ </widget>
90
+ </glade-interface>
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env ruby
2
+ #:title:RubySMS
3
+ #--
4
+ # vim: noet
5
+ #++
6
+
7
+
8
+ # during development, it's important to EXPLODE
9
+ # as early as possible when something goes wrong
10
+ Thread.abort_on_exception = true
11
+ Thread.current["name"] = "main"
12
+
13
+
14
+ # everything (should) live
15
+ # in this tidy namespace
16
+ module SMS
17
+
18
+ # store this directory name; everything
19
+ # inside here is considered to be part
20
+ # of the rubysms framework, so can be
21
+ # ignored in application backtraces
22
+ Root = File.dirname(__FILE__)
23
+ end
24
+
25
+
26
+ # load all supporting files
27
+ dir = SMS::Root + "/rubysms"
28
+ require "#{dir}/logger.rb"
29
+ require "#{dir}/router.rb"
30
+ require "#{dir}/thing.rb"
31
+ require "#{dir}/application.rb"
32
+ require "#{dir}/backend.rb"
33
+ require "#{dir}/errors.rb"
34
+ require "#{dir}/person.rb"
35
+
36
+
37
+ # message classes
38
+ require "#{dir}/message/incoming.rb"
39
+ require "#{dir}/message/outgoing.rb"
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env ruby
2
+ # vim: noet
3
+
4
+ module SMS
5
+ class App < Thing
6
+
7
+ # Creates and starts a router to serve only
8
+ # this application. Handy during development.
9
+ #
10
+ # This method accepts an arbitrary number of
11
+ # backends, each of which can be provided in
12
+ # numerous ways. This is kind of hard to wrap
13
+ # one's head around, but makes us super flexible.
14
+ # TODO: this magic will all be moved to the
15
+ # router, one day, so multiple apps
16
+ # can take advantage of it.
17
+ #
18
+ # # start the default backends
19
+ # # (one http, and one drb)
20
+ # App.serve!
21
+ #
22
+ # # just the http backend
23
+ # App.serve!(:HTTP)
24
+ #
25
+ # # the http backend... with configuration option(s)!
26
+ # # (in this case, a port). it's got to be an array,
27
+ # # so we know that we're referring to one single
28
+ # # backend here, not two "HTTP" and "8080" backends
29
+ # App.serve!([:HTTP, 8080])
30
+ #
31
+ # # two GSM backends on separate ports
32
+ # App.serve!([:GSM, "/dev/ttyS0"], [:GSM, "/dev/ttyS1"])
33
+ #
34
+ # You may notice that these arguments resemble the
35
+ # config options from the Malawi RapidSMS project...
36
+ # this is not a co-incidence.
37
+ def self.serve!(*backends)
38
+
39
+ # if no backends were explicitly requested,
40
+ # default to the HTTP + DRB offline backends
41
+ backends = [:HTTP, :DRB] if\
42
+ backends.empty?
43
+
44
+ # create a router, and attach each new backend
45
+ # in turn. because ruby's *splat operator is so
46
+ # clever, each _backend_ can be provided in many
47
+ # ways - see this method's docstring.
48
+ router = SMS::Router.new
49
+ backends.each do |backend|
50
+ router.add_backend(*backend)
51
+ end
52
+
53
+ router.add_app(self.new)
54
+ router.serve_forever
55
+ end
56
+
57
+ def incoming(msg)
58
+ if services = self.class.instance_variable_get(:@services)
59
+
60
+ # duplicate the message text before hacking it
61
+ # into pieces, so we don't alter the original
62
+ text = msg.text.dup
63
+
64
+ # lock threads while handling this message, so we don't have
65
+ # to worry about being interrupted by other incoming messages
66
+ # (in theory, this shouldn't be a problem, but it turns out
67
+ # to be a frequent source of bugs)
68
+ Thread.exclusive do
69
+ services.each do |service|
70
+ method, pattern, priority = *service
71
+
72
+ # if the pattern is a string, then assume that
73
+ # it's a case-insensitive simple trigger - it's
74
+ # a common enough use-case to warrant an exception
75
+ if pattern.is_a?(String)
76
+ pattern = /\A#{pattern}\Z/i
77
+ end
78
+
79
+ # if this pattern looks like a regex,
80
+ # attempt to match the incoming message
81
+ if pattern.respond_to?(:match)
82
+ if m = pattern.match(text)
83
+
84
+ # we have a match! attempt to
85
+ # dispatch it to the receiver
86
+ dispatch_to(method, msg, m.captures)
87
+
88
+ # the method accepted the text, but it may not be interested
89
+ # in the whole message. so crop off just the part that matched
90
+ text.sub!(pattern, "")
91
+
92
+ # stop processing if we have
93
+ # dealt with all of the text
94
+ return true unless text =~ /\S/
95
+
96
+ # there is text remaining, so
97
+ # (re-)start iterating services
98
+ # (jumps back to services.each)
99
+ retry
100
+ end
101
+
102
+ # the special :anything pattern can be used
103
+ # as a default service. once this is hit, we
104
+ # are done processing the entire message
105
+ elsif pattern == :anything
106
+ dispatch_to(method, msg, [text])
107
+ return true
108
+
109
+ # we don't understand what this pattern
110
+ # is, or how it ended up in @services.
111
+ # no big deal, but log it anyway, since
112
+ # it indicates that *something* is awry
113
+ else
114
+ log "Invalid pattern: #{pattern.inspect}", :warn
115
+ end
116
+ end#each
117
+
118
+
119
+ end#exclusive
120
+ end#if
121
+ end
122
+
123
+ def message(msg)
124
+ if msg.is_a? Symbol
125
+ begin
126
+ self.class.const_get(:Messages)[msg]
127
+
128
+ # something went wrong, but i don't
129
+ # particularly care what, right now.
130
+ # log it, and carry on regardless
131
+ rescue StandardError
132
+ log "Invalid message #{msg.inspect} for #{self.class}", :warn
133
+ "<#{msg}>"
134
+ end
135
+ else
136
+ msg
137
+ end
138
+ end
139
+
140
+ def assemble(*parts)
141
+
142
+ # the last element can be an array,
143
+ # which contains arguments to sprintf
144
+ args = parts[-1].is_a?(Array)? parts.pop : []
145
+
146
+ # resolve each remaining part
147
+ # via self#messge, which can
148
+ # (should?) be overloaded
149
+ parts.collect do |msg|
150
+ message(msg)
151
+ end.join("") % args
152
+ end
153
+
154
+ private
155
+
156
+ def dispatch_to(meth_str, msg, captures)
157
+ log_dispatch(meth_str, captures)
158
+
159
+ begin
160
+ err_line = __LINE__ + 1
161
+ send(meth_str, msg, *captures)
162
+
163
+ rescue ArgumentError => err
164
+
165
+ # if the line above (where we dispatch to the receiving
166
+ # method) caused the error, we'll log a more useful message
167
+ if (err.backtrace[0] =~ /^#{__FILE__}:#{err_line}/)
168
+ wanted = (method(meth_str).arity - 1)
169
+ problem = (captures.length > wanted) ? "Too many" : "Not enough"
170
+ log "#{problem} captures (wanted #{wanted}, got #{captures.length})", :warn
171
+
172
+ else
173
+ raise
174
+ end
175
+ end
176
+ end
177
+
178
+ # Adds a log message detailing which method is being
179
+ # invoked, with which arguments (if any)
180
+ def log_dispatch(method, args=[])
181
+ meth_str = self.class.to_s + "#" + method.to_s
182
+ meth_str += " #{args.inspect}" unless args.empty?
183
+ log "Dispatching to: #{meth_str}"
184
+ end
185
+
186
+ class << self
187
+ def serve(regex)
188
+ @serve = regex
189
+ end
190
+
191
+ def method_added(meth)
192
+ if @serve
193
+ @services = []\
194
+ unless @services
195
+
196
+ # add this method, along with the last stored
197
+ # regex, to the map of services for this app.
198
+ # the default 'incoming' method will iterate
199
+ # the regexen, and redirect the message to
200
+ # the method linked here
201
+ @services.push([meth, @serve])
202
+ @serve = nil
203
+ end
204
+ end
205
+ end
206
+ end # App
207
+ end # SMS